Spotify Link-Through & Navidrome Logo Overlay (#15)
Co-authored-by: deluan <deluan@deluan.com>
This commit is contained in:
BIN
.github/screenshot.png
vendored
BIN
.github/screenshot.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 20 KiB |
2
Makefile
2
Makefile
@@ -10,7 +10,7 @@ test:
|
||||
|
||||
build:
|
||||
ifdef TINYGO
|
||||
tinygo build -opt=2 -scheduler=none -no-debug -o $(WASM_FILE) -target wasi -buildmode=c-shared .
|
||||
tinygo build -opt=2 -scheduler=none -no-debug -o $(WASM_FILE) -target wasip1 -buildmode=c-shared .
|
||||
else
|
||||
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $(WASM_FILE) .
|
||||
endif
|
||||
|
||||
54
README.md
54
README.md
@@ -16,6 +16,9 @@ Based on the [Navicord](https://github.com/logixism/navicord) project.
|
||||
## Features
|
||||
|
||||
- Shows currently playing track with title, artist, and album art
|
||||
- Clickable track title and artist name link to Spotify (direct track link via [ListenBrainz](https://listenbrainz.org), falls back to Spotify search)
|
||||
- Clickable album art links to the Spotify track page
|
||||
- Navidrome logo overlay on album art when track artwork is available
|
||||
- Customizable activity name: "Navidrome" is default, but can be configured to display track title, artist, or album
|
||||
- Displays playback progress with start/end timestamps
|
||||
- Automatic presence clearing when track finishes
|
||||
@@ -48,6 +51,7 @@ We don't provide instructions for obtaining the token as it may violate Discord'
|
||||
- **Activity Name Display**: Choose what to show as the activity name (Default, Track, Album, Artist)
|
||||
- "Default" is recommended to help spread awareness of your favorite music server 😉, but feel free to choose the option that best suits your preferences
|
||||
- **Upload to uguu.se**: Enable this if your Navidrome isn't publicly accessible (see Album Art section below)
|
||||
- **Enable Spotify link-through**: Enable this to make track title and album art clickable links to Spotify
|
||||
- **Users**: Add your Navidrome username and Discord token from Step 3
|
||||
|
||||
### Step 5: Enable Discord Activity Sharing
|
||||
@@ -120,6 +124,11 @@ Access the plugin configuration in Navidrome: **Settings > Plugins > Discord Ric
|
||||
- **What it does**: Automatically uploads album artwork to uguu.se (temporary hosting) so Discord can display it
|
||||
- **When to disable**: Your Navidrome is publicly accessible and you've set `ND_BASEURL`
|
||||
|
||||
#### Enable Spotify Link-through
|
||||
- **Default**: Disabled
|
||||
- **What it does**: When enabled, clicking the track title or album art in Discord opens the corresponding Spotify page
|
||||
- **How it works**: Track URLs are resolved via [ListenBrainz Labs](https://labs.api.listenbrainz.org) for direct Spotify links, falling back to Spotify search when no match is found
|
||||
|
||||
#### Users
|
||||
Add each Navidrome user who wants Discord Rich Presence. For each user, provide:
|
||||
- **Username**: The Navidrome login username (case-sensitive)
|
||||
@@ -139,14 +148,14 @@ The plugin implements three Navidrome capabilities:
|
||||
|
||||
### Host Services
|
||||
|
||||
| Service | Usage |
|
||||
|-----------------|---------------------------------------------------------------------|
|
||||
| **HTTP** | Discord API calls (gateway discovery, external assets registration) |
|
||||
| **WebSocket** | Persistent connection to Discord gateway |
|
||||
| **Cache** | Sequence numbers, processed image URLs |
|
||||
| **Scheduler** | Recurring heartbeats, one-time presence clearing |
|
||||
| **Artwork** | Track artwork public URL resolution |
|
||||
| **SubsonicAPI** | Fetches track artwork data for image hosting upload |
|
||||
| Service | Usage |
|
||||
|-----------------|------------------------------------------------------------------------------------------------------|
|
||||
| **HTTP** | Discord API calls (gateway discovery, external assets registration), ListenBrainz Spotify resolution |
|
||||
| **WebSocket** | Persistent connection to Discord gateway |
|
||||
| **Cache** | Sequence numbers, processed image URLs, resolved Spotify URLs |
|
||||
| **Scheduler** | Recurring heartbeats, one-time presence clearing |
|
||||
| **Artwork** | Track artwork public URL resolution |
|
||||
| **SubsonicAPI** | Fetches track artwork data for image hosting upload |
|
||||
|
||||
### Flow
|
||||
|
||||
@@ -176,15 +185,30 @@ Discord requires images to be registered via their external assets API. The plug
|
||||
|
||||
**For non-public Navidrome instances**: If your server isn't publicly accessible (e.g., behind a VPN or firewall), enable the "Upload to uguu.se" option. This uploads artwork to a temporary file host so Discord can display it.
|
||||
|
||||
### Spotify Linking
|
||||
|
||||
The plugin enriches the Discord presence with clickable Spotify links so others can easily find what you're listening to:
|
||||
|
||||
- **Track title** → links to the Spotify track (or a Spotify search as fallback)
|
||||
- **Artist name** → links to a Spotify search for the artist
|
||||
- **Album art** → links to the Spotify track page
|
||||
|
||||
Track URLs are resolved via the [ListenBrainz Labs API](https://labs.api.listenbrainz.org):
|
||||
1. If the track has a MusicBrainz Recording ID (MBID), that is used for an exact lookup
|
||||
2. Otherwise, artist name, track title, and album are used for a metadata-based lookup
|
||||
3. If neither resolves, a Spotify search URL is used as a fallback
|
||||
|
||||
Resolved URLs are cached (30 days for direct track links, 4 hours for search fallbacks).
|
||||
|
||||
### Files
|
||||
|
||||
| File | Description |
|
||||
|--------------------------------|------------------------------------------------------------------------|
|
||||
| [main.go](main.go) | Plugin entry point, scrobbler and scheduler implementations |
|
||||
| [rpc.go](rpc.go) | Discord gateway communication, WebSocket handling, activity management |
|
||||
| [coverart.go](coverart.go) | Artwork URL handling and optional uguu.se image hosting |
|
||||
| [manifest.json](manifest.json) | Plugin metadata and permission declarations |
|
||||
| [Makefile](Makefile) | Build automation |
|
||||
| File | Description |
|
||||
|----------------------------------|-------------------------------------------------------------------------------------|
|
||||
| [main.go](main.go) | Plugin entry point, scrobbler and scheduler implementations, Spotify URL resolution |
|
||||
| [rpc.go](rpc.go) | Discord gateway communication, WebSocket handling, activity management |
|
||||
| [coverart.go](coverart.go) | Artwork URL handling and optional uguu.se image hosting |
|
||||
| [manifest.json](manifest.json) | Plugin metadata and permission declarations |
|
||||
| [Makefile](Makefile) | Build automation |
|
||||
|
||||
## Building
|
||||
|
||||
|
||||
20
coverart.go
20
coverart.go
@@ -84,17 +84,21 @@ func uploadToUguu(imageData []byte, contentType string) (string, error) {
|
||||
body = append(body, imageData...)
|
||||
body = append(body, []byte(fmt.Sprintf("\r\n--%s--\r\n", boundary))...)
|
||||
|
||||
req := pdk.NewHTTPRequest(pdk.MethodPost, "https://uguu.se/upload")
|
||||
req.SetHeader("Content-Type", fmt.Sprintf("multipart/form-data; boundary=%s", boundary))
|
||||
req.SetBody(body)
|
||||
|
||||
resp := req.Send()
|
||||
if resp.Status() >= 400 {
|
||||
return "", fmt.Errorf("uguu.se upload failed: HTTP %d", resp.Status())
|
||||
resp, err := host.HTTPSend(host.HTTPRequest{
|
||||
Method: "POST",
|
||||
URL: "https://uguu.se/upload",
|
||||
Headers: map[string]string{"Content-Type": fmt.Sprintf("multipart/form-data; boundary=%s", boundary)},
|
||||
Body: body,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("uguu.se upload failed: %w", err)
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("uguu.se upload failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result uguuResponse
|
||||
if err := json.Unmarshal(resp.Body(), &result); err != nil {
|
||||
if err := json.Unmarshal(resp.Body, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse uguu.se response: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ var _ = Describe("getImageURL", func() {
|
||||
host.ArtworkMock.Calls = nil
|
||||
host.SubsonicAPIMock.ExpectedCalls = nil
|
||||
host.SubsonicAPIMock.Calls = nil
|
||||
host.HTTPMock.ExpectedCalls = nil
|
||||
host.HTTPMock.Calls = nil
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
})
|
||||
|
||||
@@ -71,10 +73,9 @@ var _ = Describe("getImageURL", func() {
|
||||
Return("image/jpeg", imageData, nil)
|
||||
|
||||
// Mock uguu.se HTTP upload
|
||||
uguuReq := &pdk.HTTPRequest{}
|
||||
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, "https://uguu.se/upload").Return(uguuReq)
|
||||
pdk.PDKMock.On("Send", uguuReq).Return(pdk.NewStubHTTPResponse(200, nil,
|
||||
[]byte(`{"success":true,"files":[{"url":"https://a.uguu.se/uploaded.jpg"}]}`)))
|
||||
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||
return req.URL == "https://uguu.se/upload"
|
||||
})).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{"success":true,"files":[{"url":"https://a.uguu.se/uploaded.jpg"}]}`)}, nil)
|
||||
|
||||
// Mock cache set
|
||||
host.CacheMock.On("SetString", "uguu.artwork.track1", "https://a.uguu.se/uploaded.jpg", int64(9000)).Return(nil)
|
||||
@@ -98,9 +99,9 @@ var _ = Describe("getImageURL", func() {
|
||||
host.SubsonicAPIMock.On("CallRaw", "/getCoverArt?u=testuser&id=track1&size=300").
|
||||
Return("image/jpeg", []byte("fake-image-data"), nil)
|
||||
|
||||
uguuReq := &pdk.HTTPRequest{}
|
||||
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, "https://uguu.se/upload").Return(uguuReq)
|
||||
pdk.PDKMock.On("Send", uguuReq).Return(pdk.NewStubHTTPResponse(500, nil, []byte(`{"success":false}`)))
|
||||
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||
return req.URL == "https://uguu.se/upload"
|
||||
})).Return(&host.HTTPResponse{StatusCode: 500, Body: []byte(`{"success":false}`)}, nil)
|
||||
|
||||
url := getImageURL("testuser", "track1")
|
||||
Expect(url).To(BeEmpty())
|
||||
|
||||
18
go.mod
18
go.mod
@@ -1,9 +1,9 @@
|
||||
module discord-rich-presence
|
||||
|
||||
go 1.25
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260207182358-29f98b889b11
|
||||
github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260228000019-bd8032b3274a
|
||||
github.com/onsi/ginkgo/v2 v2.28.1
|
||||
github.com/onsi/gomega v1.39.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
@@ -15,19 +15,21 @@ require (
|
||||
github.com/extism/go-pdk v1.1.4-0.20260122165646-35abd9e2ba55 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // 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-20260202012954-cb029daf43ef // indirect
|
||||
github.com/maruel/natural v1.3.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/stretchr/objx v0.5.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
41
go.sum
41
go.sum
@@ -14,24 +14,29 @@ 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/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/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-20260202012954-cb029daf43ef h1:xpF9fUHpoIrrjX24DURVKiwHcFpw19ndIs+FwTSMbno=
|
||||
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef/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.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
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.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
|
||||
github.com/maruel/natural v1.3.0/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/navidrome/navidrome/plugins/pdk/go v0.0.0-20260207182358-29f98b889b11 h1:VE4bqzkS6apWDtco9hAGdThFttjbYoLR0DEILAGDyyc=
|
||||
github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260207182358-29f98b889b11/go.mod h1:HijQ0Z0OeEa6LIwUJh6H9WqAptye096jHazmKXf+YV4=
|
||||
github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260227223558-c8df2f6b8b7b h1:ztDQtaxgZv2HWu6QqqZre1SAOraUjkghWGi612tJzhg=
|
||||
github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260227223558-c8df2f6b8b7b/go.mod h1:5aedoevIXlwUFuR7kbd/WkjaiLg87D3XUFRGIwDBroo=
|
||||
github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260228000019-bd8032b3274a h1:yR7eqMqdoyZMhdGrFD/0PRoaxyDBZfSXfgHLv6B1vSg=
|
||||
github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260228000019-bd8032b3274a/go.mod h1:5aedoevIXlwUFuR7kbd/WkjaiLg87D3XUFRGIwDBroo=
|
||||
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
|
||||
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
|
||||
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
|
||||
@@ -40,8 +45,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
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/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
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=
|
||||
@@ -54,22 +59,22 @@ 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.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/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
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.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=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
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/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
38
main.go
38
main.go
@@ -28,6 +28,15 @@ const (
|
||||
clientIDKey = "clientid"
|
||||
usersKey = "users"
|
||||
activityNameKey = "activityname"
|
||||
spotifyLinksKey = "spotifylinks"
|
||||
)
|
||||
|
||||
const (
|
||||
navidromeWebsiteURL = "https://www.navidrome.org"
|
||||
|
||||
// navidromeLogoURL is the small overlay image shown in the bottom-right of the album art.
|
||||
// The file is stored in the plugins' GitHub repository so Discord can fetch it as an external asset.
|
||||
navidromeLogoURL = "https://raw.githubusercontent.com/navidrome/website/refs/heads/master/assets/icons/logo.webp"
|
||||
)
|
||||
|
||||
// Activity name display options
|
||||
@@ -147,23 +156,38 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error {
|
||||
|
||||
// Resolve the activity name based on configuration
|
||||
activityName := "Navidrome"
|
||||
statusDisplayType := statusDisplayDetails
|
||||
activityNameOption, _ := pdk.GetConfig(activityNameKey)
|
||||
switch activityNameOption {
|
||||
case activityNameTrack:
|
||||
activityName = input.Track.Title
|
||||
statusDisplayType = statusDisplayName
|
||||
case activityNameAlbum:
|
||||
activityName = input.Track.Album
|
||||
statusDisplayType = statusDisplayName
|
||||
case activityNameArtist:
|
||||
activityName = input.Track.Artist
|
||||
statusDisplayType = statusDisplayName
|
||||
}
|
||||
|
||||
// Resolve Spotify URLs if enabled
|
||||
var spotifyURL, artistSearchURL string
|
||||
spotifyLinksOption, _ := pdk.GetConfig(spotifyLinksKey)
|
||||
if spotifyLinksOption == "true" {
|
||||
spotifyURL = resolveSpotifyURL(input.Track)
|
||||
artistSearchURL = spotifySearchURL(input.Track.Artist)
|
||||
}
|
||||
|
||||
// Send activity update
|
||||
if err := rpc.sendActivity(clientID, input.Username, userToken, activity{
|
||||
Application: clientID,
|
||||
Name: activityName,
|
||||
Type: 2, // Listening
|
||||
Details: input.Track.Title,
|
||||
State: input.Track.Artist,
|
||||
Application: clientID,
|
||||
Name: activityName,
|
||||
Type: 2, // Listening
|
||||
Details: input.Track.Title,
|
||||
DetailsURL: spotifyURL,
|
||||
State: input.Track.Artist,
|
||||
StateURL: artistSearchURL,
|
||||
StatusDisplayType: statusDisplayType,
|
||||
Timestamps: activityTimestamps{
|
||||
Start: startTime,
|
||||
End: endTime,
|
||||
@@ -171,6 +195,10 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error {
|
||||
Assets: activityAssets{
|
||||
LargeImage: getImageURL(input.Username, input.Track.ID),
|
||||
LargeText: input.Track.Album,
|
||||
LargeURL: spotifyURL,
|
||||
SmallImage: navidromeLogoURL,
|
||||
SmallText: "Navidrome",
|
||||
SmallURL: navidromeWebsiteURL,
|
||||
},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("%w: failed to send activity: %v", scrobbler.ScrobblerErrorRetryLater, err)
|
||||
|
||||
59
main_test.go
59
main_test.go
@@ -33,6 +33,8 @@ var _ = Describe("discordPlugin", func() {
|
||||
host.ArtworkMock.Calls = nil
|
||||
host.SubsonicAPIMock.ExpectedCalls = nil
|
||||
host.SubsonicAPIMock.Calls = nil
|
||||
host.HTTPMock.ExpectedCalls = nil
|
||||
host.HTTPMock.Calls = nil
|
||||
})
|
||||
|
||||
Describe("getConfig", func() {
|
||||
@@ -121,15 +123,16 @@ var _ = Describe("discordPlugin", func() {
|
||||
pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"testuser","token":"test-token"}]`, true)
|
||||
pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false)
|
||||
pdk.PDKMock.On("GetConfig", activityNameKey).Return("", false)
|
||||
pdk.PDKMock.On("GetConfig", spotifyLinksKey).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()
|
||||
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||
return req.Method == "GET" && req.URL == "https://discord.com/api/gateway"
|
||||
})).Return(&host.HTTPResponse{StatusCode: 200, Body: gatewayResp}, nil)
|
||||
|
||||
// Mock WebSocket connection
|
||||
host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool {
|
||||
@@ -141,19 +144,13 @@ var _ = Describe("discordPlugin", func() {
|
||||
// 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)
|
||||
// Cache mocks (Discord image processing)
|
||||
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
|
||||
host.CacheMock.On("SetString", discordImageKey, 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"}`)))
|
||||
// Mock HTTP POST requests (Discord external assets API)
|
||||
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{}`)}, nil)
|
||||
|
||||
// Schedule clear activity callback
|
||||
host.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil)
|
||||
@@ -173,18 +170,19 @@ var _ = Describe("discordPlugin", func() {
|
||||
})
|
||||
|
||||
DescribeTable("activity name configuration",
|
||||
func(configValue string, configExists bool, expectedName string) {
|
||||
func(configValue string, configExists bool, expectedName string, expectedDisplayType int) {
|
||||
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", uguuEnabledKey).Return("", false)
|
||||
pdk.PDKMock.On("GetConfig", activityNameKey).Return(configValue, configExists)
|
||||
pdk.PDKMock.On("GetConfig", spotifyLinksKey).Return("", false)
|
||||
|
||||
// Connect mocks
|
||||
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found"))
|
||||
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()
|
||||
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||
return req.Method == "GET" && req.URL == "https://discord.com/api/gateway"
|
||||
})).Return(&host.HTTPResponse{StatusCode: 200, Body: gatewayResp}, nil)
|
||||
host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool {
|
||||
return strings.Contains(url, "gateway.discord.gg")
|
||||
}), mock.Anything, "testuser").Return("testuser", nil)
|
||||
@@ -197,17 +195,11 @@ var _ = Describe("discordPlugin", func() {
|
||||
host.SchedulerMock.On("ScheduleRecurring", mock.Anything, payloadHeartbeat, "testuser").Return("testuser", nil)
|
||||
host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil)
|
||||
|
||||
// Image mocks
|
||||
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)
|
||||
// Cache mocks (Discord image processing)
|
||||
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
|
||||
host.CacheMock.On("SetString", discordImageKey, mock.Anything, mock.Anything).Return(nil)
|
||||
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil)
|
||||
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"}`)))
|
||||
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{}`)}, nil)
|
||||
host.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil)
|
||||
|
||||
err := plugin.NowPlaying(scrobbler.NowPlayingRequest{
|
||||
@@ -223,12 +215,13 @@ var _ = Describe("discordPlugin", func() {
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sentPayload).To(ContainSubstring(fmt.Sprintf(`"name":"%s"`, expectedName)))
|
||||
Expect(sentPayload).To(ContainSubstring(fmt.Sprintf(`"status_display_type":%d`, expectedDisplayType)))
|
||||
},
|
||||
Entry("defaults to Navidrome when not configured", "", false, "Navidrome"),
|
||||
Entry("defaults to Navidrome with explicit default value", "Default", true, "Navidrome"),
|
||||
Entry("uses track title when configured", "Track", true, "Test Song"),
|
||||
Entry("uses track album when configured", "Album", true, "Test Album"),
|
||||
Entry("uses track artist when configured", "Artist", true, "Test Artist"),
|
||||
Entry("defaults to Navidrome when not configured", "", false, "Navidrome", 2),
|
||||
Entry("defaults to Navidrome with explicit default value", "Default", true, "Navidrome", 2),
|
||||
Entry("uses track title when configured", "Track", true, "Test Song", 0),
|
||||
Entry("uses track album when configured", "Album", true, "Test Album", 0),
|
||||
Entry("uses track artist when configured", "Artist", true, "Test Artist", 0),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -10,10 +10,11 @@
|
||||
"reason": "To process scrobbles on behalf of users"
|
||||
},
|
||||
"http": {
|
||||
"reason": "To communicate with Discord API for gateway discovery and image uploads",
|
||||
"reason": "To communicate with Discord API, image uploads, and ListenBrainz for track resolution",
|
||||
"requiredHosts": [
|
||||
"discord.com",
|
||||
"uguu.se"
|
||||
"uguu.se",
|
||||
"labs.api.listenbrainz.org"
|
||||
]
|
||||
},
|
||||
"websocket": {
|
||||
@@ -64,6 +65,12 @@
|
||||
"title": "Upload artwork to uguu.se (enable if Navidrome is not publicly accessible)",
|
||||
"default": false
|
||||
},
|
||||
"spotifylinks": {
|
||||
"type": "boolean",
|
||||
"title": "Enable Spotify link-through",
|
||||
"description": "When enabled, clicking the track title or album art in Discord opens the corresponding Spotify page",
|
||||
"default": false
|
||||
},
|
||||
"users": {
|
||||
"type": "array",
|
||||
"title": "User Tokens",
|
||||
@@ -115,6 +122,10 @@
|
||||
"type": "Control",
|
||||
"scope": "#/properties/uguuenabled"
|
||||
},
|
||||
{
|
||||
"type": "Control",
|
||||
"scope": "#/properties/spotifylinks"
|
||||
},
|
||||
{
|
||||
"type": "Control",
|
||||
"scope": "#/properties/users",
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func TestDiscordPlugin(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Discord Plugin Main Suite")
|
||||
}
|
||||
|
||||
// Shared matchers for tighter mock expectations across all test files.
|
||||
var (
|
||||
discordImageKey = mock.MatchedBy(func(key string) bool { return strings.HasPrefix(key, "discord.image.") })
|
||||
externalAssetsReq = mock.MatchedBy(func(req host.HTTPRequest) bool { return strings.Contains(req.URL, "external-assets") })
|
||||
spotifyURLKey = mock.MatchedBy(func(key string) bool { return strings.HasPrefix(key, "spotify.url.") })
|
||||
)
|
||||
|
||||
232
rpc.go
232
rpc.go
@@ -3,6 +3,11 @@
|
||||
// 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.
|
||||
//
|
||||
// References:
|
||||
// - Gateway Events (official): https://docs.discord.com/developers/events/gateway-events
|
||||
// - Activity object (community): https://discord-api-types.dev/api/next/discord-api-types-v10/interface/GatewayActivity
|
||||
// - Presence resources (community): https://docs.discord.food/resources/presence
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -15,16 +20,10 @@ import (
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/websocket"
|
||||
)
|
||||
|
||||
// Discord WebSocket Gateway constants
|
||||
// Image cache TTL 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"
|
||||
imageCacheTTL int64 = 4 * 60 * 60 // 4 hours for track artwork
|
||||
defaultImageCacheTTL int64 = 48 * 60 * 60 // 48 hours for default Navidrome logo
|
||||
)
|
||||
|
||||
// Scheduler callback payloads for routing
|
||||
@@ -36,6 +35,75 @@ const (
|
||||
// discordRPC handles Discord gateway communication and implements WebSocket callbacks.
|
||||
type discordRPC struct{}
|
||||
|
||||
// ============================================================================
|
||||
// Discord types and constants
|
||||
// ============================================================================
|
||||
|
||||
// Discord WebSocket Gateway constants
|
||||
const (
|
||||
heartbeatOpCode = 1 // Heartbeat operation code
|
||||
gateOpCode = 2 // Identify operation code
|
||||
presenceOpCode = 3 // Presence update operation code
|
||||
)
|
||||
|
||||
// Discord status_display_type values control how the activity is shown in the member list.
|
||||
const (
|
||||
statusDisplayName = 0 // Show activity name in member list
|
||||
statusDisplayState = 1 // Show state field in member list
|
||||
statusDisplayDetails = 2 // Show details field in member list
|
||||
)
|
||||
|
||||
const heartbeatInterval = 41 // Heartbeat interval in seconds
|
||||
|
||||
// activity represents a Discord activity sent via Gateway opcode 3.
|
||||
type activity struct {
|
||||
Name string `json:"name"`
|
||||
Type int `json:"type"`
|
||||
Details string `json:"details"`
|
||||
DetailsURL string `json:"details_url,omitempty"`
|
||||
State string `json:"state"`
|
||||
StateURL string `json:"state_url,omitempty"`
|
||||
Application string `json:"application_id"`
|
||||
StatusDisplayType int `json:"status_display_type"`
|
||||
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"`
|
||||
LargeURL string `json:"large_url,omitempty"`
|
||||
SmallImage string `json:"small_image,omitempty"`
|
||||
SmallText string `json:"small_text,omitempty"`
|
||||
SmallURL string `json:"small_url,omitempty"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WebSocket Callback Implementation
|
||||
// ============================================================================
|
||||
@@ -63,59 +131,15 @@ func (r *discordRPC) OnClose(input websocket.OnCloseRequest) error {
|
||||
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) {
|
||||
// processImage processes an image URL for Discord. Returns the processed image
|
||||
// string (mp:prefixed) or an error. No fallback logic — the caller handles retries.
|
||||
func (r *discordRPC) processImage(imageURL, clientID, token string, ttl int64) (string, error) {
|
||||
if imageURL == "" {
|
||||
if isDefaultImage {
|
||||
return "", fmt.Errorf("default image URL is empty")
|
||||
}
|
||||
return r.processImage(defaultImage, clientID, token, true)
|
||||
return "", fmt.Errorf("image URL is empty")
|
||||
}
|
||||
|
||||
if strings.HasPrefix(imageURL, "mp:") {
|
||||
@@ -123,7 +147,7 @@ func (r *discordRPC) processImage(imageURL, clientID, token string, isDefaultIma
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
cacheKey := fmt.Sprintf("discord.image.%x", imageURL)
|
||||
cacheKey := "discord.image." + hashKey(imageURL)
|
||||
cachedValue, exists, err := host.CacheGetString(cacheKey)
|
||||
if err == nil && exists {
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("Cache hit for image URL: %s", imageURL))
|
||||
@@ -132,50 +156,36 @@ func (r *discordRPC) processImage(imageURL, clientID, token string, isDefaultIma
|
||||
|
||||
// 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)
|
||||
resp, err := host.HTTPSend(host.HTTPRequest{
|
||||
Method: "POST",
|
||||
URL: fmt.Sprintf("https://discord.com/api/v9/applications/%s/external-assets", clientID),
|
||||
Headers: map[string]string{"Authorization": token, "Content-Type": "application/json"},
|
||||
Body: []byte(body),
|
||||
})
|
||||
if err != nil {
|
||||
pdk.Log(pdk.LogWarn, fmt.Sprintf("HTTP request failed for image processing: %v", err))
|
||||
return "", fmt.Errorf("failed to process image: %w", err)
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("failed to process image: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
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 err := json.Unmarshal(resp.Body, &data); err != nil {
|
||||
return "", fmt.Errorf("failed to unmarshal image response: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
if isDefaultImage {
|
||||
return "", fmt.Errorf("no data returned for default image")
|
||||
}
|
||||
return r.processImage(defaultImage, clientID, token, true)
|
||||
return "", fmt.Errorf("no data returned for image")
|
||||
}
|
||||
|
||||
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)
|
||||
return "", fmt.Errorf("empty external_asset_path for image")
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
@@ -190,14 +200,38 @@ func (r *discordRPC) processImage(imageURL, clientID, token string, isDefaultIma
|
||||
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)
|
||||
// Try track artwork first, fall back to Navidrome logo
|
||||
usingDefaultImage := false
|
||||
processedImage, err := r.processImage(data.Assets.LargeImage, clientID, token, imageCacheTTL)
|
||||
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 = ""
|
||||
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to process track image for user %s: %v, falling back to default", username, err))
|
||||
processedImage, err = r.processImage(navidromeLogoURL, clientID, token, defaultImageCacheTTL)
|
||||
if err != nil {
|
||||
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to process default image for user %s: %v, continuing without image", username, err))
|
||||
data.Assets.LargeImage = ""
|
||||
} else {
|
||||
data.Assets.LargeImage = processedImage
|
||||
usingDefaultImage = true
|
||||
}
|
||||
} else {
|
||||
data.Assets.LargeImage = processedImage
|
||||
}
|
||||
|
||||
// Only show SmallImage (Navidrome logo overlay) when LargeImage is actual track artwork
|
||||
if usingDefaultImage || data.Assets.LargeImage == "" {
|
||||
data.Assets.SmallImage = ""
|
||||
data.Assets.SmallText = ""
|
||||
} else if data.Assets.SmallImage != "" {
|
||||
processedSmall, err := r.processImage(data.Assets.SmallImage, clientID, token, defaultImageCacheTTL)
|
||||
if err != nil {
|
||||
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to process small image for user %s: %v", username, err))
|
||||
data.Assets.SmallImage = ""
|
||||
data.Assets.SmallText = ""
|
||||
} else {
|
||||
data.Assets.SmallImage = processedSmall
|
||||
}
|
||||
}
|
||||
|
||||
presence := presencePayload{
|
||||
Activities: []activity{data},
|
||||
Status: "dnd",
|
||||
@@ -236,14 +270,20 @@ func (r *discordRPC) sendMessage(username string, opCode int, payload any) error
|
||||
|
||||
// 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())
|
||||
resp, err := host.HTTPSend(host.HTTPRequest{
|
||||
Method: "GET",
|
||||
URL: "https://discord.com/api/gateway",
|
||||
})
|
||||
if err != nil {
|
||||
pdk.Log(pdk.LogWarn, fmt.Sprintf("HTTP request failed for Discord gateway: %v", err))
|
||||
return "", fmt.Errorf("failed to get Discord gateway: %w", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("failed to get Discord gateway: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result map[string]string
|
||||
if err := json.Unmarshal(resp.Body(), &result); err != nil {
|
||||
if err := json.Unmarshal(resp.Body, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse Discord gateway response: %w", err)
|
||||
}
|
||||
return result["url"], nil
|
||||
|
||||
204
rpc_test.go
204
rpc_test.go
@@ -25,6 +25,8 @@ var _ = Describe("discordRPC", func() {
|
||||
host.WebSocketMock.Calls = nil
|
||||
host.SchedulerMock.ExpectedCalls = nil
|
||||
host.SchedulerMock.Calls = nil
|
||||
host.HTTPMock.ExpectedCalls = nil
|
||||
host.HTTPMock.Calls = nil
|
||||
})
|
||||
|
||||
Describe("sendMessage", func() {
|
||||
@@ -81,9 +83,9 @@ var _ = Describe("discordRPC", func() {
|
||||
|
||||
// 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))
|
||||
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||
return req.Method == "GET" && req.URL == "https://discord.com/api/gateway"
|
||||
})).Return(&host.HTTPResponse{StatusCode: 200, Body: gatewayResp}, nil)
|
||||
|
||||
// Mock WebSocket connection
|
||||
host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool {
|
||||
@@ -232,26 +234,103 @@ var _ = Describe("discordRPC", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("processImage", func() {
|
||||
BeforeEach(func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
})
|
||||
|
||||
It("returns error for empty URL", func() {
|
||||
_, err := r.processImage("", "client123", "token123", imageCacheTTL)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("image URL is empty"))
|
||||
})
|
||||
|
||||
It("returns mp: prefixed URL as-is", func() {
|
||||
result, err := r.processImage("mp:external/abc123", "client123", "token123", imageCacheTTL)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(Equal("mp:external/abc123"))
|
||||
})
|
||||
|
||||
It("returns cached value on cache hit", func() {
|
||||
host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool {
|
||||
return strings.HasPrefix(key, "discord.image.")
|
||||
})).Return("mp:cached/image", true, nil)
|
||||
|
||||
result, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(Equal("mp:cached/image"))
|
||||
})
|
||||
|
||||
It("processes image via Discord API and caches result", func() {
|
||||
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
|
||||
host.CacheMock.On("SetString", discordImageKey, mock.MatchedBy(func(val string) bool {
|
||||
return val == "mp:external/new-asset"
|
||||
}), int64(imageCacheTTL)).Return(nil)
|
||||
|
||||
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[{"external_asset_path":"external/new-asset"}]`)}, nil)
|
||||
|
||||
result, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(Equal("mp:external/new-asset"))
|
||||
})
|
||||
|
||||
It("returns error on HTTP failure", func() {
|
||||
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
|
||||
|
||||
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 500, Body: []byte(`error`)}, nil)
|
||||
|
||||
_, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("HTTP 500"))
|
||||
})
|
||||
|
||||
It("returns error on unmarshal failure", func() {
|
||||
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
|
||||
|
||||
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{"not":"an-array"}`)}, nil)
|
||||
|
||||
_, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("failed to unmarshal"))
|
||||
})
|
||||
|
||||
It("returns error on empty response array", func() {
|
||||
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
|
||||
|
||||
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[]`)}, nil)
|
||||
|
||||
_, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("no data returned"))
|
||||
})
|
||||
|
||||
It("returns error on empty external_asset_path", func() {
|
||||
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
|
||||
|
||||
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[{"external_asset_path":""}]`)}, nil)
|
||||
|
||||
_, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("empty external_asset_path"))
|
||||
})
|
||||
})
|
||||
|
||||
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() {
|
||||
It("sends activity with track artwork and SmallImage overlay", func() {
|
||||
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
|
||||
host.CacheMock.On("SetString", discordImageKey, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[{"external_asset_path":"external/art"}]`)}, nil)
|
||||
|
||||
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"`)
|
||||
strings.Contains(msg, `"large_image":"mp:external/art"`) &&
|
||||
strings.Contains(msg, `"small_image":"mp:external/art"`) &&
|
||||
strings.Contains(msg, `"small_text":"Navidrome"`)
|
||||
})).Return(nil)
|
||||
|
||||
err := r.sendActivity("client123", "testuser", "token123", activity{
|
||||
@@ -260,6 +339,99 @@ var _ = Describe("discordRPC", func() {
|
||||
Type: 2,
|
||||
State: "Test Artist",
|
||||
Details: "Test Album",
|
||||
Assets: activityAssets{
|
||||
LargeImage: "https://example.com/art.jpg",
|
||||
LargeText: "Test Album",
|
||||
SmallImage: navidromeLogoURL,
|
||||
SmallText: "Navidrome",
|
||||
},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("falls back to default image and clears SmallImage", func() {
|
||||
// Track art fails (HTTP error), default image succeeds
|
||||
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
|
||||
host.CacheMock.On("SetString", discordImageKey, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
// First call (track art) returns 500, second call (default) succeeds
|
||||
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 500, Body: []byte(`error`)}, nil).Once()
|
||||
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[{"external_asset_path":"external/logo"}]`)}, nil).Once()
|
||||
|
||||
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
|
||||
return strings.Contains(msg, `"op":3`) &&
|
||||
strings.Contains(msg, `"large_image":"mp:external/logo"`) &&
|
||||
!strings.Contains(msg, `"small_image":"mp:`) &&
|
||||
!strings.Contains(msg, `"small_text":"Navidrome"`)
|
||||
})).Return(nil)
|
||||
|
||||
err := r.sendActivity("client123", "testuser", "token123", activity{
|
||||
Application: "client123",
|
||||
Name: "Test Song",
|
||||
Type: 2,
|
||||
State: "Test Artist",
|
||||
Details: "Test Album",
|
||||
Assets: activityAssets{
|
||||
LargeImage: "https://example.com/art.jpg",
|
||||
LargeText: "Test Album",
|
||||
SmallImage: navidromeLogoURL,
|
||||
SmallText: "Navidrome",
|
||||
},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("clears all images when both track art and default fail", func() {
|
||||
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
|
||||
|
||||
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{"not":"array"}`)}, nil)
|
||||
|
||||
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
|
||||
return strings.Contains(msg, `"op":3`) &&
|
||||
strings.Contains(msg, `"large_image":""`) &&
|
||||
!strings.Contains(msg, `"small_image":"mp:`)
|
||||
})).Return(nil)
|
||||
|
||||
err := r.sendActivity("client123", "testuser", "token123", activity{
|
||||
Application: "client123",
|
||||
Name: "Test Song",
|
||||
Type: 2,
|
||||
State: "Test Artist",
|
||||
Details: "Test Album",
|
||||
Assets: activityAssets{
|
||||
LargeImage: "https://example.com/art.jpg",
|
||||
LargeText: "Test Album",
|
||||
SmallImage: navidromeLogoURL,
|
||||
SmallText: "Navidrome",
|
||||
},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("handles SmallImage processing failure gracefully", func() {
|
||||
// LargeImage from cache (succeeds), SmallImage API fails
|
||||
host.CacheMock.On("GetString", discordImageKey).Return("mp:cached/large", true, nil).Once()
|
||||
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil).Once()
|
||||
|
||||
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 500, Body: []byte(`error`)}, nil)
|
||||
|
||||
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
|
||||
return strings.Contains(msg, `"large_image":"mp:cached/large"`) &&
|
||||
!strings.Contains(msg, `"small_image":"mp:`)
|
||||
})).Return(nil)
|
||||
|
||||
err := r.sendActivity("client123", "testuser", "token123", activity{
|
||||
Application: "client123",
|
||||
Name: "Test Song",
|
||||
Type: 2,
|
||||
State: "Test Artist",
|
||||
Details: "Test Album",
|
||||
Assets: activityAssets{
|
||||
LargeImage: "https://example.com/art.jpg",
|
||||
LargeText: "Test Album",
|
||||
SmallImage: navidromeLogoURL,
|
||||
SmallText: "Navidrome",
|
||||
},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
180
spotify.go
Normal file
180
spotify.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
|
||||
)
|
||||
|
||||
// hashKey returns a hex-encoded FNV-1a hash of s, for use as a cache key suffix.
|
||||
func hashKey(s string) string {
|
||||
const offset64 uint64 = 14695981039346656037
|
||||
const prime64 uint64 = 1099511628211
|
||||
h := offset64
|
||||
for i := 0; i < len(s); i++ {
|
||||
h ^= uint64(s[i])
|
||||
h *= prime64
|
||||
}
|
||||
return fmt.Sprintf("%016x", h)
|
||||
}
|
||||
|
||||
const (
|
||||
spotifyCacheTTLHit int64 = 30 * 24 * 60 * 60 // 30 days for resolved track IDs
|
||||
spotifyCacheTTLMiss int64 = 4 * 60 * 60 // 4 hours for misses (retry later)
|
||||
)
|
||||
|
||||
// listenBrainzResult captures the relevant field from ListenBrainz Labs JSON responses.
|
||||
// The API returns spotify_track_ids as an array of strings.
|
||||
type listenBrainzResult struct {
|
||||
SpotifyTrackIDs []string `json:"spotify_track_ids"`
|
||||
}
|
||||
|
||||
// spotifySearchURL builds a Spotify search URL from one or more terms.
|
||||
// Empty terms are ignored. Returns "" if all terms are empty.
|
||||
func spotifySearchURL(terms ...string) string {
|
||||
query := strings.TrimSpace(strings.Join(terms, " "))
|
||||
if query == "" {
|
||||
return ""
|
||||
}
|
||||
return "https://open.spotify.com/search/" + url.PathEscape(query)
|
||||
}
|
||||
|
||||
// spotifyCacheKey returns a deterministic cache key for a track's Spotify URL.
|
||||
func spotifyCacheKey(artist, title, album string) string {
|
||||
return "spotify.url." + hashKey(strings.ToLower(artist)+"\x00"+strings.ToLower(title)+"\x00"+strings.ToLower(album))
|
||||
}
|
||||
|
||||
// trySpotifyFromMBID calls the ListenBrainz spotify-id-from-mbid endpoint.
|
||||
func trySpotifyFromMBID(mbid string) string {
|
||||
body := fmt.Sprintf(`[{"recording_mbid":%q}]`, mbid)
|
||||
resp, err := host.HTTPSend(host.HTTPRequest{
|
||||
Method: "POST",
|
||||
URL: "https://labs.api.listenbrainz.org/spotify-id-from-mbid/json",
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
Body: []byte(body),
|
||||
})
|
||||
if err != nil {
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("ListenBrainz MBID lookup request failed: %v", err))
|
||||
return ""
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz MBID lookup failed: HTTP %d, body=%s", resp.StatusCode, string(resp.Body)))
|
||||
return ""
|
||||
}
|
||||
id := parseSpotifyID(resp.Body)
|
||||
if id == "" {
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz MBID lookup returned no spotify_track_id for mbid=%s, body=%s", mbid, string(resp.Body)))
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// trySpotifyFromMetadata calls the ListenBrainz spotify-id-from-metadata endpoint.
|
||||
func trySpotifyFromMetadata(artist, title, album string) string {
|
||||
payload := fmt.Sprintf(`[{"artist_name":%q,"track_name":%q,"release_name":%q}]`, artist, title, album)
|
||||
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz metadata request: %s", payload))
|
||||
|
||||
resp, err := host.HTTPSend(host.HTTPRequest{
|
||||
Method: "POST",
|
||||
URL: "https://labs.api.listenbrainz.org/spotify-id-from-metadata/json",
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
Body: []byte(payload),
|
||||
})
|
||||
if err != nil {
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("ListenBrainz metadata lookup request failed: %v", err))
|
||||
return ""
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz metadata lookup failed: HTTP %d, body=%s", resp.StatusCode, string(resp.Body)))
|
||||
return ""
|
||||
}
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz metadata response: HTTP %d, body=%s", resp.StatusCode, string(resp.Body)))
|
||||
id := parseSpotifyID(resp.Body)
|
||||
if id == "" {
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz metadata returned no spotify_track_id for %q - %q", artist, title))
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// parseSpotifyID extracts the first spotify track ID from a ListenBrainz Labs JSON response.
|
||||
// The response is an array of objects with spotify_track_ids arrays; we take the first non-empty ID.
|
||||
func parseSpotifyID(body []byte) string {
|
||||
var results []listenBrainzResult
|
||||
if err := json.Unmarshal(body, &results); err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, r := range results {
|
||||
for _, id := range r.SpotifyTrackIDs {
|
||||
if isValidSpotifyID(id) {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isValidSpotifyID checks that a Spotify track ID is non-empty and contains only base-62 characters.
|
||||
func isValidSpotifyID(id string) bool {
|
||||
if len(id) == 0 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(id); i++ {
|
||||
c := id[i]
|
||||
if !((c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// resolveSpotifyURL resolves a direct Spotify track URL via ListenBrainz Labs,
|
||||
// falling back to a search URL. Results are cached.
|
||||
func resolveSpotifyURL(track scrobbler.TrackInfo) string {
|
||||
var primary string
|
||||
if len(track.Artists) > 0 {
|
||||
primary = track.Artists[0].Name
|
||||
}
|
||||
|
||||
cacheKey := spotifyCacheKey(primary, track.Title, track.Album)
|
||||
|
||||
if cached, exists, err := host.CacheGetString(cacheKey); err == nil && exists {
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("Spotify URL cache hit for %q - %q → %s", primary, track.Title, cached))
|
||||
return cached
|
||||
}
|
||||
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("Resolving Spotify URL for: artist=%q title=%q album=%q mbid=%q", primary, track.Title, track.Album, track.MBZRecordingID))
|
||||
|
||||
// 1. Try MBID lookup (most accurate)
|
||||
if track.MBZRecordingID != "" {
|
||||
if trackID := trySpotifyFromMBID(track.MBZRecordingID); trackID != "" {
|
||||
directURL := "https://open.spotify.com/track/" + trackID
|
||||
_ = host.CacheSetString(cacheKey, directURL, spotifyCacheTTLHit)
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("Resolved Spotify via MBID for %q: %s", track.Title, directURL))
|
||||
return directURL
|
||||
}
|
||||
pdk.Log(pdk.LogDebug, "MBID lookup did not return a Spotify ID, trying metadata…")
|
||||
} else {
|
||||
pdk.Log(pdk.LogDebug, "No MBZRecordingID available, skipping MBID lookup")
|
||||
}
|
||||
|
||||
// 2. Try metadata lookup
|
||||
if primary != "" && track.Title != "" {
|
||||
if trackID := trySpotifyFromMetadata(primary, track.Title, track.Album); trackID != "" {
|
||||
directURL := "https://open.spotify.com/track/" + trackID
|
||||
_ = host.CacheSetString(cacheKey, directURL, spotifyCacheTTLHit)
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("Resolved Spotify via metadata for %q - %q: %s", primary, track.Title, directURL))
|
||||
return directURL
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback to search URL
|
||||
searchURL := spotifySearchURL(track.Artist, track.Title)
|
||||
_ = host.CacheSetString(cacheKey, searchURL, spotifyCacheTTLMiss)
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("Spotify resolution missed, falling back to search URL for %q - %q: %s", primary, track.Title, searchURL))
|
||||
return searchURL
|
||||
}
|
||||
219
spotify_test.go
Normal file
219
spotify_test.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
|
||||
"github.com/stretchr/testify/mock"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Spotify", func() {
|
||||
Describe("spotifySearchURL", func() {
|
||||
DescribeTable("constructs Spotify search URL",
|
||||
func(expectedURL string, terms ...string) {
|
||||
Expect(spotifySearchURL(terms...)).To(Equal(expectedURL))
|
||||
},
|
||||
Entry("artist and title", "https://open.spotify.com/search/Rick%20Astley%20Never%20Gonna%20Give%20You%20Up", "Rick Astley", "Never Gonna Give You Up"),
|
||||
Entry("single term", "https://open.spotify.com/search/Radiohead", "Radiohead"),
|
||||
Entry("empty terms", "", "", ""),
|
||||
Entry("one empty term", "https://open.spotify.com/search/Solo%20Artist", "Solo Artist", ""),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("spotifyCacheKey", func() {
|
||||
It("produces identical keys for identical inputs", func() {
|
||||
key1 := spotifyCacheKey("Radiohead", "Karma Police", "OK Computer")
|
||||
key2 := spotifyCacheKey("Radiohead", "Karma Police", "OK Computer")
|
||||
Expect(key1).To(Equal(key2))
|
||||
})
|
||||
|
||||
It("produces different keys for different albums", func() {
|
||||
key1 := spotifyCacheKey("Radiohead", "Karma Police", "OK Computer")
|
||||
key2 := spotifyCacheKey("Radiohead", "Karma Police", "The Bends")
|
||||
Expect(key1).ToNot(Equal(key2))
|
||||
})
|
||||
|
||||
It("uses the correct prefix", func() {
|
||||
key := spotifyCacheKey("Radiohead", "Karma Police", "OK Computer")
|
||||
Expect(key).To(HavePrefix("spotify.url."))
|
||||
})
|
||||
|
||||
It("is case-insensitive", func() {
|
||||
keyUpper := spotifyCacheKey("Radiohead", "Karma Police", "OK Computer")
|
||||
keyLower := spotifyCacheKey("radiohead", "karma police", "ok computer")
|
||||
Expect(keyUpper).To(Equal(keyLower))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("parseSpotifyID", func() {
|
||||
DescribeTable("extracts first Spotify track ID from ListenBrainz response",
|
||||
func(body, expectedID string) {
|
||||
Expect(parseSpotifyID([]byte(body))).To(Equal(expectedID))
|
||||
},
|
||||
Entry("valid single result",
|
||||
`[{"spotify_track_ids":["4tIGK5G9hNDA50ZdGioZRG"]}]`, "4tIGK5G9hNDA50ZdGioZRG"),
|
||||
Entry("multiple IDs picks first",
|
||||
`[{"artist_name":"Lil Baby & Drake","track_name":"Yes Indeed","spotify_track_ids":["6vN77lE9LK6HP2DewaN6HZ","4wlLbLeDWbA6TzwZFp1UaK"]}]`, "6vN77lE9LK6HP2DewaN6HZ"),
|
||||
Entry("valid result with extra fields",
|
||||
`[{"artist_name":"Radiohead","track_name":"Karma Police","spotify_track_ids":["63OQupATfueTdZMWIV7nzz"],"release_name":"OK Computer"}]`, "63OQupATfueTdZMWIV7nzz"),
|
||||
Entry("empty spotify_track_ids array",
|
||||
`[{"spotify_track_ids":[]}]`, ""),
|
||||
Entry("no spotify_track_ids field",
|
||||
`[{"artist_name":"Unknown"}]`, ""),
|
||||
Entry("empty array",
|
||||
`[]`, ""),
|
||||
Entry("invalid JSON",
|
||||
`not json`, ""),
|
||||
Entry("null first result falls through to second",
|
||||
`[{"spotify_track_ids":[]},{"spotify_track_ids":["6vN77lE9LK6HP2DewaN6HZ"]}]`, "6vN77lE9LK6HP2DewaN6HZ"),
|
||||
Entry("skips invalid ID with special characters",
|
||||
`[{"spotify_track_ids":["abc!@#$%^&*()_+=-12345"]}]`, ""),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("isValidSpotifyID", func() {
|
||||
DescribeTable("validates Spotify track IDs",
|
||||
func(id string, expected bool) {
|
||||
Expect(isValidSpotifyID(id)).To(Equal(expected))
|
||||
},
|
||||
Entry("valid 22-char ID", "6vN77lE9LK6HP2DewaN6HZ", true),
|
||||
Entry("another valid ID", "4tIGK5G9hNDA50ZdGioZRG", true),
|
||||
Entry("short valid ID", "abc123", true),
|
||||
Entry("special characters", "6vN77lE9!K6HP2DewaN6HZ", false),
|
||||
Entry("spaces", "6vN77 E9LK6HP2DewaN6HZ", false),
|
||||
Entry("empty string", "", false),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("ListenBrainz request payloads", func() {
|
||||
It("builds valid JSON for MBID requests", func() {
|
||||
mbid := "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
||||
body := []byte(fmt.Sprintf(`[{"recording_mbid":%q}]`, mbid))
|
||||
var parsed []map[string]string
|
||||
Expect(json.Unmarshal(body, &parsed)).To(Succeed())
|
||||
Expect(parsed[0]["recording_mbid"]).To(Equal(mbid))
|
||||
})
|
||||
|
||||
It("builds valid JSON for metadata requests with special characters", func() {
|
||||
artist := `Guns N' Roses`
|
||||
title := `Sweet Child O' Mine`
|
||||
album := `Appetite for Destruction`
|
||||
payload := fmt.Sprintf(`[{"artist_name":%q,"track_name":%q,"release_name":%q}]`, artist, title, album)
|
||||
var parsed []map[string]string
|
||||
Expect(json.Unmarshal([]byte(payload), &parsed)).To(Succeed())
|
||||
Expect(parsed[0]["artist_name"]).To(Equal(artist))
|
||||
Expect(parsed[0]["track_name"]).To(Equal(title))
|
||||
Expect(parsed[0]["release_name"]).To(Equal(album))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("resolveSpotifyURL", func() {
|
||||
BeforeEach(func() {
|
||||
pdk.ResetMock()
|
||||
host.CacheMock.ExpectedCalls = nil
|
||||
host.CacheMock.Calls = nil
|
||||
host.HTTPMock.ExpectedCalls = nil
|
||||
host.HTTPMock.Calls = nil
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
})
|
||||
|
||||
It("returns cached URL on cache hit", func() {
|
||||
host.CacheMock.On("GetString", spotifyURLKey).Return("https://open.spotify.com/track/cached123", true, nil)
|
||||
|
||||
url := resolveSpotifyURL(scrobbler.TrackInfo{
|
||||
Title: "Karma Police",
|
||||
Artist: "Radiohead",
|
||||
Artists: []scrobbler.ArtistRef{{Name: "Radiohead"}},
|
||||
Album: "OK Computer",
|
||||
})
|
||||
Expect(url).To(Equal("https://open.spotify.com/track/cached123"))
|
||||
})
|
||||
|
||||
It("resolves via MBID when available", func() {
|
||||
host.CacheMock.On("GetString", spotifyURLKey).Return("", false, nil)
|
||||
host.CacheMock.On("SetString", spotifyURLKey, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
// Mock the MBID HTTP request
|
||||
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||
return req.URL == "https://labs.api.listenbrainz.org/spotify-id-from-mbid/json"
|
||||
})).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[{"spotify_track_ids":["63OQupATfueTdZMWIV7nzz"]}]`)}, nil)
|
||||
|
||||
url := resolveSpotifyURL(scrobbler.TrackInfo{
|
||||
Title: "Karma Police",
|
||||
Artist: "Radiohead",
|
||||
Artists: []scrobbler.ArtistRef{{Name: "Radiohead"}},
|
||||
Album: "OK Computer",
|
||||
MBZRecordingID: "mbid-123",
|
||||
})
|
||||
Expect(url).To(Equal("https://open.spotify.com/track/63OQupATfueTdZMWIV7nzz"))
|
||||
host.CacheMock.AssertCalled(GinkgoT(), "SetString", spotifyURLKey, "https://open.spotify.com/track/63OQupATfueTdZMWIV7nzz", spotifyCacheTTLHit)
|
||||
})
|
||||
|
||||
It("falls back to metadata lookup when MBID fails", func() {
|
||||
host.CacheMock.On("GetString", spotifyURLKey).Return("", false, nil)
|
||||
host.CacheMock.On("SetString", spotifyURLKey, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
// MBID request fails
|
||||
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||
return req.URL == "https://labs.api.listenbrainz.org/spotify-id-from-mbid/json"
|
||||
})).Return(&host.HTTPResponse{StatusCode: 404, Body: []byte(`[]`)}, nil)
|
||||
|
||||
// Metadata request succeeds
|
||||
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||
return req.URL == "https://labs.api.listenbrainz.org/spotify-id-from-metadata/json"
|
||||
})).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[{"spotify_track_ids":["4wlLbLeDWbA6TzwZFp1UaK"]}]`)}, nil)
|
||||
|
||||
url := resolveSpotifyURL(scrobbler.TrackInfo{
|
||||
Title: "Karma Police",
|
||||
Artist: "Radiohead",
|
||||
Artists: []scrobbler.ArtistRef{{Name: "Radiohead"}},
|
||||
Album: "OK Computer",
|
||||
MBZRecordingID: "mbid-123",
|
||||
})
|
||||
Expect(url).To(Equal("https://open.spotify.com/track/4wlLbLeDWbA6TzwZFp1UaK"))
|
||||
})
|
||||
|
||||
It("falls back to search URL when both lookups fail", func() {
|
||||
host.CacheMock.On("GetString", spotifyURLKey).Return("", false, nil)
|
||||
host.CacheMock.On("SetString", spotifyURLKey, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
// No MBID, metadata request fails
|
||||
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||
return req.URL == "https://labs.api.listenbrainz.org/spotify-id-from-metadata/json"
|
||||
})).Return(&host.HTTPResponse{StatusCode: 500, Body: []byte(`error`)}, nil)
|
||||
|
||||
url := resolveSpotifyURL(scrobbler.TrackInfo{
|
||||
Title: "Karma Police",
|
||||
Artist: "Radiohead",
|
||||
Artists: []scrobbler.ArtistRef{{Name: "Radiohead"}},
|
||||
Album: "OK Computer",
|
||||
})
|
||||
Expect(url).To(HavePrefix("https://open.spotify.com/search/"))
|
||||
Expect(url).To(ContainSubstring("Radiohead"))
|
||||
host.CacheMock.AssertCalled(GinkgoT(), "SetString", spotifyURLKey, mock.Anything, spotifyCacheTTLMiss)
|
||||
})
|
||||
|
||||
It("uses Artists[0] for primary artist", func() {
|
||||
host.CacheMock.On("GetString", spotifyURLKey).Return("", false, nil)
|
||||
host.CacheMock.On("SetString", spotifyURLKey, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||
return req.URL == "https://labs.api.listenbrainz.org/spotify-id-from-metadata/json"
|
||||
})).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[{"spotify_track_ids":["4tIGK5G9hNDA50ZdGioZRG"]}]`)}, nil)
|
||||
|
||||
url := resolveSpotifyURL(scrobbler.TrackInfo{
|
||||
Title: "Some Song",
|
||||
Artist: "",
|
||||
Album: "Some Album",
|
||||
Artists: []scrobbler.ArtistRef{{Name: "Fallback Artist"}},
|
||||
})
|
||||
Expect(url).To(Equal("https://open.spotify.com/track/4tIGK5G9hNDA50ZdGioZRG"))
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user