diff --git a/.github/screenshot.png b/.github/screenshot.png index 4f18b73..afccccd 100644 Binary files a/.github/screenshot.png and b/.github/screenshot.png differ diff --git a/Makefile b/Makefile index c6cccc5..bfc51d1 100644 --- a/Makefile +++ b/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 diff --git a/README.md b/README.md index b190fe5..738f2de 100644 --- a/README.md +++ b/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 diff --git a/coverart.go b/coverart.go index 46592a0..81b8ceb 100644 --- a/coverart.go +++ b/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) } diff --git a/coverart_test.go b/coverart_test.go index e3131e8..8d9eeeb 100644 --- a/coverart_test.go +++ b/coverart_test.go @@ -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()) diff --git a/go.mod b/go.mod index dbaa9df..758bebd 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index 702fa22..e34c1cd 100644 --- a/go.sum +++ b/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= diff --git a/logo.webp b/logo.webp new file mode 100644 index 0000000..3623608 Binary files /dev/null and b/logo.webp differ diff --git a/main.go b/main.go index be5a81e..52871f8 100644 --- a/main.go +++ b/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) diff --git a/main_test.go b/main_test.go index cd9aa90..ce399fb 100644 --- a/main_test.go +++ b/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), ) }) diff --git a/manifest.json b/manifest.json index 42cfdd4..ee95130 100644 --- a/manifest.json +++ b/manifest.json @@ -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", diff --git a/plugin_suite_test.go b/plugin_suite_test.go index ad01992..9e183c7 100644 --- a/plugin_suite_test.go +++ b/plugin_suite_test.go @@ -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.") }) +) diff --git a/rpc.go b/rpc.go index 229bc0f..7b2c573 100644 --- a/rpc.go +++ b/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 diff --git a/rpc_test.go b/rpc_test.go index b85c27e..be5dca5 100644 --- a/rpc_test.go +++ b/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()) }) diff --git a/spotify.go b/spotify.go new file mode 100644 index 0000000..0bc135a --- /dev/null +++ b/spotify.go @@ -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 +} diff --git a/spotify_test.go b/spotify_test.go new file mode 100644 index 0000000..0926dd8 --- /dev/null +++ b/spotify_test.go @@ -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")) + }) + }) +})