From f96884e3e5c41957a68b0a7a6cb61489405198d0 Mon Sep 17 00:00:00 2001 From: WoahAI <115117306+Woahai321@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:04:03 -0500 Subject: [PATCH] Spotify Link-Through & Navidrome Logo Overlay (#15) Co-authored-by: deluan --- .github/screenshot.png | Bin 52139 -> 20591 bytes Makefile | 2 +- README.md | 54 +++++++--- coverart.go | 20 ++-- coverart_test.go | 15 +-- go.mod | 18 ++-- go.sum | 41 ++++---- logo.webp | Bin 0 -> 18872 bytes main.go | 38 ++++++- main_test.go | 59 +++++------ manifest.json | 15 ++- plugin_suite_test.go | 10 ++ rpc.go | 232 ++++++++++++++++++++++++----------------- rpc_test.go | 204 +++++++++++++++++++++++++++++++++--- spotify.go | 180 ++++++++++++++++++++++++++++++++ spotify_test.go | 219 ++++++++++++++++++++++++++++++++++++++ 16 files changed, 898 insertions(+), 209 deletions(-) create mode 100644 logo.webp create mode 100644 spotify.go create mode 100644 spotify_test.go diff --git a/.github/screenshot.png b/.github/screenshot.png index 4f18b731cb56702f5d890470fcacb9f096a12202..afccccd3efcb3db0be7ed3f19afc49c52ef7ba44 100644 GIT binary patch delta 20182 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 M>FVdQ&MBb@04oyQh5!Hn 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 0000000000000000000000000000000000000000..36236080bc2751f3ab26e8e548913a94417eb772 GIT binary patch literal 18872 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 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")) + }) + }) +})