Compare commits

1 Commits
main ... v0.3

19 changed files with 295 additions and 1139 deletions

BIN
.github/screenshot.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 51 KiB

View File

@@ -30,20 +30,6 @@ jobs:
- name: Run tests - name: Run tests
run: make test run: make test
- name: Append PR info to version
if: github.event_name == 'pull_request'
run: |
PR_NUM=${{ github.event.pull_request.number }}
SHA=$(echo "${{ github.event.pull_request.head.sha }}" | cut -c1-7)
SUFFIX="PR${PR_NUM}-${SHA}"
jq --arg suffix "$SUFFIX" '.version = .version + "-" + $suffix' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
- name: Append git SHA to version
if: github.event_name == 'push'
run: |
SHA=$(echo "${{ github.sha }}" | cut -c1-7)
jq --arg sha "$SHA" '.version = .version + "-" + $sha' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
- name: Build and package plugin - name: Build and package plugin
run: make package run: make package

View File

@@ -7,11 +7,6 @@ on:
description: "Release version (e.g., 1.2.3, without the 'v' prefix)" description: "Release version (e.g., 1.2.3, without the 'v' prefix)"
required: true required: true
type: string type: string
prerelease:
description: "Mark this as a pre-release"
required: false
type: boolean
default: false
permissions: permissions:
contents: write contents: write
@@ -25,23 +20,17 @@ jobs:
env: env:
VERSION: ${{ inputs.version }} VERSION: ${{ inputs.version }}
run: | run: |
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+.*$ ]]; then
echo "::error::Invalid version format '$VERSION'. Use X.X.X (e.g., 1.2.3)" echo "::error::Invalid version format '$VERSION'. Use X.X.X (e.g., 1.2.3)"
exit 1 exit 1
fi fi
- name: Compute full version
run: |
VERSION="${{ inputs.version }}"
if [[ "${{ inputs.prerelease }}" == "true" ]]; then
VERSION="${VERSION}-prerelease"
fi
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
- name: Check out code - name: Check out code
uses: actions/checkout@v5 uses: actions/checkout@v5
- name: Check tag does not already exist - name: Check tag does not already exist
env:
VERSION: ${{ inputs.version }}
run: | run: |
if git ls-remote --tags origin "refs/tags/v${VERSION}" | grep -q .; then if git ls-remote --tags origin "refs/tags/v${VERSION}" | grep -q .; then
echo "::error::Tag v${VERSION} already exists" echo "::error::Tag v${VERSION} already exists"
@@ -57,10 +46,14 @@ jobs:
run: go test -race ./... run: go test -race ./...
- name: Update manifest.json version - name: Update manifest.json version
env:
VERSION: ${{ inputs.version }}
run: | run: |
jq --arg v "$VERSION" '.version = $v' manifest.json > manifest.tmp && mv manifest.tmp manifest.json jq --arg v "$VERSION" '.version = $v' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
- name: Commit, tag, and push - name: Commit, tag, and push
env:
VERSION: ${{ inputs.version }}
run: | run: |
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
@@ -81,8 +74,7 @@ jobs:
- name: Create release - name: Create release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: v${{ env.VERSION }} tag_name: v${{ inputs.version }}
draft: true draft: true
prerelease: ${{ inputs.prerelease }}
files: discord-rich-presence.ndp files: discord-rich-presence.ndp
generate_release_notes: true generate_release_notes: true

1
.gitignore vendored
View File

@@ -1,5 +1,4 @@
*.wasm *.wasm
*.ndp *.ndp
tmp tmp
discord-rich-presence
.DS_Store .DS_Store

View File

@@ -3,18 +3,12 @@ SHELL := /usr/bin/env bash
PLUGIN_NAME := discord-rich-presence PLUGIN_NAME := discord-rich-presence
WASM_FILE := plugin.wasm WASM_FILE := plugin.wasm
TINYGO := $(shell command -v tinygo 2> /dev/null)
test: test:
go test -race ./... go test -race ./...
build: 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
package: build package: build
zip $(PLUGIN_NAME).ndp $(WASM_FILE) manifest.json zip $(PLUGIN_NAME).ndp $(WASM_FILE) manifest.json
@@ -23,7 +17,7 @@ clean:
rm -f $(WASM_FILE) $(PLUGIN_NAME).ndp rm -f $(WASM_FILE) $(PLUGIN_NAME).ndp
release: release:
@if [[ ! "${V}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$$ ]]; then echo "Usage: make release V=X.X.X [PRE=true]"; exit 1; fi @if [[ ! "${V}" =~ ^[0-9]+\.[0-9]+\.[0-9]+.*$$ ]]; then echo "Usage: make release V=X.X.X"; exit 1; fi
gh workflow run create-release.yml -f version=${V} -f prerelease=$(if $(filter true,$(PRE)),true,false) gh workflow run create-release.yml -f version=${V}
@echo "Release v${V}$(if $(filter true,$(PRE)),-prerelease,) workflow triggered. Check progress: gh run list --workflow=create-release.yml" @echo "Release v${V} workflow triggered. Check progress: gh run list --workflow=create-release.yml"
.PHONY: release .PHONY: release

145
README.md
View File

@@ -3,8 +3,6 @@
[![Build](https://github.com/navidrome/discord-rich-presence-plugin/actions/workflows/build.yml/badge.svg)](https://github.com/navidrome/discord-rich-presence-plugin/actions/workflows/build.yml) [![Build](https://github.com/navidrome/discord-rich-presence-plugin/actions/workflows/build.yml/badge.svg)](https://github.com/navidrome/discord-rich-presence-plugin/actions/workflows/build.yml)
[![Latest](https://img.shields.io/github/v/release/navidrome/discord-rich-presence-plugin)](https://github.com/navidrome/discord-rich-presence-plugin/releases/latest/download/discord-rich-presence.ndp) [![Latest](https://img.shields.io/github/v/release/navidrome/discord-rich-presence-plugin)](https://github.com/navidrome/discord-rich-presence-plugin/releases/latest/download/discord-rich-presence.ndp)
**Attention: This plugin requires Navidrome 0.60.2 or later.**
This plugin integrates Navidrome with Discord Rich Presence, displaying your currently playing track in your Discord status. This plugin integrates Navidrome with Discord Rich Presence, displaying your currently playing track in your Discord status.
The goal is to demonstrate the capabilities of Navidrome's plugin system by implementing a real-time presence feature using Discord's Gateway API. The goal is to demonstrate the capabilities of Navidrome's plugin system by implementing a real-time presence feature using Discord's Gateway API.
It demonstrates how a Navidrome plugin can maintain real-time connections to external services while remaining completely stateless. It demonstrates how a Navidrome plugin can maintain real-time connections to external services while remaining completely stateless.
@@ -16,16 +14,13 @@ Based on the [Navicord](https://github.com/logixism/navicord) project.
## Features ## Features
- Shows currently playing track with title, artist, and album art - 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 - Customizable activity name: "Navidrome" is default, but can be configured to display track title, artist, or album
- Displays playback progress with start/end timestamps - Displays playback progress with start/end timestamps
- Automatic presence clearing when track finishes - Automatic presence clearing when track finishes
- Multi-user support with individual Discord tokens - Multi-user support with individual Discord tokens
- Optional image hosting via [uguu.se](https://uguu.se) for non-public Navidrome instances - Optional image hosting via [uguu.se](https://uguu.se) for non-public Navidrome instances
<img alt="Discord Rich Presence showing currently playing track with album art, artist, and playback progress" src="https://raw.githubusercontent.com/navidrome/discord-rich-presence-plugin/master/.github/screenshot.png"> <img height="550" alt="Discord Rich Presence showing currently playing track with album art, artist, and playback progress" src="https://raw.githubusercontent.com/navidrome/discord-rich-presence-plugin/master/.github/screenshot.png">
## Installation ## Installation
@@ -48,10 +43,7 @@ We don't provide instructions for obtaining the token as it may violate Discord'
1. Open Navidrome and go to **Settings > Plugins > Discord Rich Presence** 1. Open Navidrome and go to **Settings > Plugins > Discord Rich Presence**
2. Fill in the configuration: 2. Fill in the configuration:
- **Client ID**: Your Discord Application ID from Step 2 - **Client ID**: Your Discord Application ID from Step 2
- **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) - **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 - **Users**: Add your Navidrome username and Discord token from Step 3
### Step 5: Enable Discord Activity Sharing ### Step 5: Enable Discord Activity Sharing
@@ -97,43 +89,6 @@ For album artwork to display in Discord, Discord needs to be able to access the
- **Using public instance**: Verify ND_BASEURL is correct and Navidrome was restarted - **Using public instance**: Verify ND_BASEURL is correct and Navidrome was restarted
- **Using uguu.se**: Check that the option is enabled and your server has internet access - **Using uguu.se**: Check that the option is enabled and your server has internet access
## Configuration
Access the plugin configuration in Navidrome: **Settings > Plugins > Discord Rich Presence**
### Configuration Fields
#### Client ID
- **What it is**: Your Discord Application ID
- **How to get it**:
1. Go to [Discord Developer Portal](https://discord.com/developers/applications)
2. Create a new application or select an existing one
3. Copy the "Application ID" from the General Information page
- **Example**: `1234567890123456789`
#### Activity Name Display
- **What it is**: Choose what information to display as the activity name in Discord Rich Presence
- **Options**:
- **Default**: Shows "Navidrome" (static app name)
- **Track**: Shows the currently playing track title
- **Album**: Shows the currently playing track's album name
- **Artist**: Shows the currently playing track's artist name
#### Upload to uguu.se
- **When to enable**: Your Navidrome instance is NOT publicly accessible from the internet
- **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)
- **Token**: The Discord user token (see Step 3 in Installation for how to obtain this)
## How It Works ## How It Works
### Plugin Capabilities ### Plugin Capabilities
@@ -148,14 +103,14 @@ The plugin implements three Navidrome capabilities:
### Host Services ### Host Services
| Service | Usage | | Service | Usage |
|-----------------|------------------------------------------------------------------------------------------------------| |-----------------|---------------------------------------------------------------------|
| **HTTP** | Discord API calls (gateway discovery, external assets registration), ListenBrainz Spotify resolution | | **HTTP** | Discord API calls (gateway discovery, external assets registration) |
| **WebSocket** | Persistent connection to Discord gateway | | **WebSocket** | Persistent connection to Discord gateway |
| **Cache** | Sequence numbers, processed image URLs, resolved Spotify URLs | | **Cache** | Sequence numbers, processed image URLs |
| **Scheduler** | Recurring heartbeats, one-time presence clearing | | **Scheduler** | Recurring heartbeats, one-time presence clearing |
| **Artwork** | Track artwork public URL resolution | | **Artwork** | Track artwork public URL resolution |
| **SubsonicAPI** | Fetches track artwork data for image hosting upload | | **SubsonicAPI** | Fetches track artwork data for image hosting upload |
### Flow ### Flow
@@ -185,30 +140,68 @@ 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. **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 ### Files
| File | Description | | File | Description |
|----------------------------------|-------------------------------------------------------------------------------------| |--------------------------------|------------------------------------------------------------------------|
| [main.go](main.go) | Plugin entry point, scrobbler and scheduler implementations, Spotify URL resolution | | [main.go](main.go) | Plugin entry point, scrobbler and scheduler implementations |
| [rpc.go](rpc.go) | Discord gateway communication, WebSocket handling, activity management | | [rpc.go](rpc.go) | Discord gateway communication, WebSocket handling, activity management |
| [coverart.go](coverart.go) | Artwork URL handling and optional uguu.se image hosting | | [coverart.go](coverart.go) | Artwork URL handling and optional uguu.se image hosting |
| [manifest.json](manifest.json) | Plugin metadata and permission declarations | | [manifest.json](manifest.json) | Plugin metadata and permission declarations |
| [Makefile](Makefile) | Build automation | | [Makefile](Makefile) | Build automation |
## Configuration
Access the plugin configuration in Navidrome: **Settings > Plugins > Discord Rich Presence**
### Configuration Fields
#### Client ID
- **What it is**: Your Discord Application ID
- **How to get it**:
1. Go to [Discord Developer Portal](https://discord.com/developers/applications)
2. Create a new application or select an existing one
3. Copy the "Application ID" from the General Information page
- **Example**: `1234567890123456789`
#### Activity Name Display
- **What it is**: Choose what information to display as the activity name in Discord Rich Presence
- **Options**:
- **Default**: Shows "Navidrome" (static app name)
- **Track**: Shows the currently playing track title
- **Album**: Shows the currently playing track's album name
- **Artist**: Shows the currently playing track's artist name
- **Default**: "Default"
- **Use case**: Choose "Track" or "Artist" for more dynamic, music-focused presence that changes with each song
#### Upload to uguu.se
- **When to enable**: Your Navidrome instance is NOT publicly accessible from the internet
- **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`
#### Users
Add each Navidrome user who wants Discord Rich Presence:
**Format**: Array of user objects with `username` and `token` fields
**Example**:
```json
[
{
"username": "john",
"token": "your-discord-user-token-here"
},
{
"username": "jane",
"token": "another-discord-user-token"
}
]
```
**Important**:
- `username`: Your Navidrome login username (case-sensitive)
- `token`: Your Discord user token (see installation instructions for how to obtain this)
## Building ## Building

View File

@@ -84,21 +84,17 @@ func uploadToUguu(imageData []byte, contentType string) (string, error) {
body = append(body, imageData...) body = append(body, imageData...)
body = append(body, []byte(fmt.Sprintf("\r\n--%s--\r\n", boundary))...) body = append(body, []byte(fmt.Sprintf("\r\n--%s--\r\n", boundary))...)
resp, err := host.HTTPSend(host.HTTPRequest{ req := pdk.NewHTTPRequest(pdk.MethodPost, "https://uguu.se/upload")
Method: "POST", req.SetHeader("Content-Type", fmt.Sprintf("multipart/form-data; boundary=%s", boundary))
URL: "https://uguu.se/upload", req.SetBody(body)
Headers: map[string]string{"Content-Type": fmt.Sprintf("multipart/form-data; boundary=%s", boundary)},
Body: body, resp := req.Send()
}) if resp.Status() >= 400 {
if err != nil { return "", fmt.Errorf("uguu.se upload failed: HTTP %d", resp.Status())
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 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) return "", fmt.Errorf("failed to parse uguu.se response: %w", err)
} }

View File

@@ -20,8 +20,6 @@ var _ = Describe("getImageURL", func() {
host.ArtworkMock.Calls = nil host.ArtworkMock.Calls = nil
host.SubsonicAPIMock.ExpectedCalls = nil host.SubsonicAPIMock.ExpectedCalls = nil
host.SubsonicAPIMock.Calls = nil host.SubsonicAPIMock.Calls = nil
host.HTTPMock.ExpectedCalls = nil
host.HTTPMock.Calls = nil
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
}) })
@@ -73,9 +71,10 @@ var _ = Describe("getImageURL", func() {
Return("image/jpeg", imageData, nil) Return("image/jpeg", imageData, nil)
// Mock uguu.se HTTP upload // Mock uguu.se HTTP upload
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { uguuReq := &pdk.HTTPRequest{}
return req.URL == "https://uguu.se/upload" pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, "https://uguu.se/upload").Return(uguuReq)
})).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{"success":true,"files":[{"url":"https://a.uguu.se/uploaded.jpg"}]}`)}, nil) pdk.PDKMock.On("Send", uguuReq).Return(pdk.NewStubHTTPResponse(200, nil,
[]byte(`{"success":true,"files":[{"url":"https://a.uguu.se/uploaded.jpg"}]}`)))
// Mock cache set // Mock cache set
host.CacheMock.On("SetString", "uguu.artwork.track1", "https://a.uguu.se/uploaded.jpg", int64(9000)).Return(nil) host.CacheMock.On("SetString", "uguu.artwork.track1", "https://a.uguu.se/uploaded.jpg", int64(9000)).Return(nil)
@@ -99,9 +98,9 @@ var _ = Describe("getImageURL", func() {
host.SubsonicAPIMock.On("CallRaw", "/getCoverArt?u=testuser&id=track1&size=300"). host.SubsonicAPIMock.On("CallRaw", "/getCoverArt?u=testuser&id=track1&size=300").
Return("image/jpeg", []byte("fake-image-data"), nil) Return("image/jpeg", []byte("fake-image-data"), nil)
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { uguuReq := &pdk.HTTPRequest{}
return req.URL == "https://uguu.se/upload" pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, "https://uguu.se/upload").Return(uguuReq)
})).Return(&host.HTTPResponse{StatusCode: 500, Body: []byte(`{"success":false}`)}, nil) pdk.PDKMock.On("Send", uguuReq).Return(pdk.NewStubHTTPResponse(500, nil, []byte(`{"success":false}`)))
url := getImageURL("testuser", "track1") url := getImageURL("testuser", "track1")
Expect(url).To(BeEmpty()) Expect(url).To(BeEmpty())

20
go.mod
View File

@@ -1,9 +1,9 @@
module discord-rich-presence module discord-rich-presence
go 1.25.0 go 1.25
require ( require (
github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260303204839-f03ca44a8ec4 github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260207182358-29f98b889b11
github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/ginkgo/v2 v2.28.1
github.com/onsi/gomega v1.39.1 github.com/onsi/gomega v1.39.1
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
@@ -15,21 +15,19 @@ require (
github.com/extism/go-pdk v1.1.4-0.20260122165646-35abd9e2ba55 // indirect github.com/extism/go-pdk v1.1.4-0.20260122165646-35abd9e2ba55 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // 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/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef // indirect
github.com/maruel/natural v1.3.0 // indirect github.com/maruel/natural v1.3.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/stretchr/objx v0.5.3 // indirect github.com/stretchr/objx v0.5.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.33.0 // indirect golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.51.0 // indirect golang.org/x/net v0.49.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.42.0 // indirect golang.org/x/tools v0.41.0 // indirect
google.golang.org/protobuf v1.36.11 // 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 gopkg.in/yaml.v3 v3.0.1 // indirect
) )

43
go.sum
View File

@@ -14,27 +14,24 @@ 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-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 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw= github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef h1:xpF9fUHpoIrrjX24DURVKiwHcFpw19ndIs+FwTSMbno=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= 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 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= 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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= 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 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260303204839-f03ca44a8ec4 h1:LgSTogYiu31eQF8BMh3fDuIcZ82chzIZDi/U/HZYYbA= github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260207182358-29f98b889b11 h1:VE4bqzkS6apWDtco9hAGdThFttjbYoLR0DEILAGDyyc=
github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260303204839-f03ca44a8ec4/go.mod h1:5aedoevIXlwUFuR7kbd/WkjaiLg87D3XUFRGIwDBroo= github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260207182358-29f98b889b11/go.mod h1:HijQ0Z0OeEa6LIwUJh6H9WqAptye096jHazmKXf+YV4=
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= 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/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
@@ -43,8 +40,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/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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
@@ -57,22 +54,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= 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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

BIN
logo.webp

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

48
main.go
View File

@@ -29,15 +29,6 @@ const (
usersKey = "users" usersKey = "users"
activityNameKey = "activityname" activityNameKey = "activityname"
activityNameTemplateKey = "activitynametemplate" activityNameTemplateKey = "activitynametemplate"
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 // Activity name display options
@@ -158,48 +149,31 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error {
// Resolve the activity name based on configuration // Resolve the activity name based on configuration
activityName := "Navidrome" activityName := "Navidrome"
statusDisplayType := statusDisplayDetails
activityNameOption, _ := pdk.GetConfig(activityNameKey) activityNameOption, _ := pdk.GetConfig(activityNameKey)
switch activityNameOption { switch activityNameOption {
case activityNameTrack: case activityNameTrack:
activityName = input.Track.Title activityName = input.Track.Title
statusDisplayType = statusDisplayName
case activityNameAlbum: case activityNameAlbum:
activityName = input.Track.Album activityName = input.Track.Album
statusDisplayType = statusDisplayName
case activityNameArtist: case activityNameArtist:
activityName = input.Track.Artist activityName = input.Track.Artist
statusDisplayType = statusDisplayName
case activityNameCustom: case activityNameCustom:
template, _ := pdk.GetConfig(activityNameTemplateKey) template, _ := pdk.GetConfig(activityNameTemplateKey)
if template != "" { if template != "" {
r := strings.NewReplacer( activityName = template
"{track}", input.Track.Title, activityName = strings.ReplaceAll(activityName, "{track}", input.Track.Title)
"{artist}", input.Track.Artist, activityName = strings.ReplaceAll(activityName, "{artist}", input.Track.Artist)
"{album}", input.Track.Album, activityName = strings.ReplaceAll(activityName, "{album}", input.Track.Album)
)
activityName = r.Replace(template)
} }
} }
// 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 // Send activity update
if err := rpc.sendActivity(clientID, input.Username, userToken, activity{ if err := rpc.sendActivity(clientID, input.Username, userToken, activity{
Application: clientID, Application: clientID,
Name: activityName, Name: activityName,
Type: 2, // Listening Type: 2, // Listening
Details: input.Track.Title, Details: input.Track.Title,
DetailsURL: spotifyURL, State: input.Track.Artist,
State: input.Track.Artist,
StateURL: artistSearchURL,
StatusDisplayType: statusDisplayType,
Timestamps: activityTimestamps{ Timestamps: activityTimestamps{
Start: startTime, Start: startTime,
End: endTime, End: endTime,
@@ -207,10 +181,6 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error {
Assets: activityAssets{ Assets: activityAssets{
LargeImage: getImageURL(input.Username, input.Track.ID), LargeImage: getImageURL(input.Username, input.Track.ID),
LargeText: input.Track.Album, LargeText: input.Track.Album,
LargeURL: spotifyURL,
SmallImage: navidromeLogoURL,
SmallText: "Navidrome",
SmallURL: navidromeWebsiteURL,
}, },
}); err != nil { }); err != nil {
return fmt.Errorf("%w: failed to send activity: %v", scrobbler.ScrobblerErrorRetryLater, err) return fmt.Errorf("%w: failed to send activity: %v", scrobbler.ScrobblerErrorRetryLater, err)

View File

@@ -33,8 +33,6 @@ var _ = Describe("discordPlugin", func() {
host.ArtworkMock.Calls = nil host.ArtworkMock.Calls = nil
host.SubsonicAPIMock.ExpectedCalls = nil host.SubsonicAPIMock.ExpectedCalls = nil
host.SubsonicAPIMock.Calls = nil host.SubsonicAPIMock.Calls = nil
host.HTTPMock.ExpectedCalls = nil
host.HTTPMock.Calls = nil
}) })
Describe("getConfig", func() { Describe("getConfig", func() {
@@ -123,16 +121,15 @@ var _ = Describe("discordPlugin", func() {
pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"testuser","token":"test-token"}]`, true) pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"testuser","token":"test-token"}]`, true)
pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false) pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false)
pdk.PDKMock.On("GetConfig", activityNameKey).Return("", false) pdk.PDKMock.On("GetConfig", activityNameKey).Return("", false)
pdk.PDKMock.On("GetConfig", spotifyLinksKey).Return("", false)
// Connect mocks (isConnected check via heartbeat) // Connect mocks (isConnected check via heartbeat)
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found")) host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found"))
// Mock HTTP GET request for gateway discovery // Mock HTTP GET request for gateway discovery
gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`) gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`)
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { gatewayReq := &pdk.HTTPRequest{}
return req.Method == "GET" && req.URL == "https://discord.com/api/gateway" pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://discord.com/api/gateway").Return(gatewayReq).Once()
})).Return(&host.HTTPResponse{StatusCode: 200, Body: gatewayResp}, nil) pdk.PDKMock.On("Send", gatewayReq).Return(pdk.NewStubHTTPResponse(200, nil, gatewayResp)).Once()
// Mock WebSocket connection // Mock WebSocket connection
host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool { host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool {
@@ -144,13 +141,19 @@ var _ = Describe("discordPlugin", func() {
// Cancel existing clear schedule (may or may not exist) // Cancel existing clear schedule (may or may not exist)
host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil) host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil)
// Cache mocks (Discord image processing) // Image mocks - cache miss, will make HTTP request to Discord
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil) host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool {
host.CacheMock.On("SetString", discordImageKey, mock.Anything, mock.Anything).Return(nil) return strings.HasPrefix(key, "discord.image.")
})).Return("", false, nil)
host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil)
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil) host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil)
// Mock HTTP POST requests (Discord external assets API) // Mock HTTP request for Discord external assets API
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{}`)}, 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"}`)))
// Schedule clear activity callback // Schedule clear activity callback
host.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil) host.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil)
@@ -170,19 +173,18 @@ var _ = Describe("discordPlugin", func() {
}) })
DescribeTable("activity name configuration", DescribeTable("activity name configuration",
func(configValue string, configExists bool, expectedName string, expectedDisplayType int) { func(configValue string, configExists bool, expectedName string) {
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true) 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", usersKey).Return(`[{"username":"testuser","token":"test-token"}]`, true)
pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false) pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false)
pdk.PDKMock.On("GetConfig", activityNameKey).Return(configValue, configExists) pdk.PDKMock.On("GetConfig", activityNameKey).Return(configValue, configExists)
pdk.PDKMock.On("GetConfig", spotifyLinksKey).Return("", false)
// Connect mocks // Connect mocks
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found")) host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found"))
gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`) gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`)
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { gatewayReq := &pdk.HTTPRequest{}
return req.Method == "GET" && req.URL == "https://discord.com/api/gateway" pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://discord.com/api/gateway").Return(gatewayReq).Once()
})).Return(&host.HTTPResponse{StatusCode: 200, Body: gatewayResp}, nil) pdk.PDKMock.On("Send", gatewayReq).Return(pdk.NewStubHTTPResponse(200, nil, gatewayResp)).Once()
host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool { host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool {
return strings.Contains(url, "gateway.discord.gg") return strings.Contains(url, "gateway.discord.gg")
}), mock.Anything, "testuser").Return("testuser", nil) }), mock.Anything, "testuser").Return("testuser", nil)
@@ -195,11 +197,17 @@ var _ = Describe("discordPlugin", func() {
host.SchedulerMock.On("ScheduleRecurring", mock.Anything, payloadHeartbeat, "testuser").Return("testuser", nil) host.SchedulerMock.On("ScheduleRecurring", mock.Anything, payloadHeartbeat, "testuser").Return("testuser", nil)
host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil) host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil)
// Cache mocks (Discord image processing) // Image mocks
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil) host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool {
host.CacheMock.On("SetString", discordImageKey, mock.Anything, mock.Anything).Return(nil) return strings.HasPrefix(key, "discord.image.")
})).Return("", false, nil)
host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil)
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil) host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil)
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{}`)}, 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.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil) host.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil)
err := plugin.NowPlaying(scrobbler.NowPlayingRequest{ err := plugin.NowPlaying(scrobbler.NowPlayingRequest{
@@ -215,13 +223,12 @@ var _ = Describe("discordPlugin", func() {
}) })
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(sentPayload).To(ContainSubstring(fmt.Sprintf(`"name":"%s"`, expectedName))) 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", 2), Entry("defaults to Navidrome when not configured", "", false, "Navidrome"),
Entry("defaults to Navidrome with explicit default value", "Default", true, "Navidrome", 2), Entry("defaults to Navidrome with explicit default value", "Default", true, "Navidrome"),
Entry("uses track title when configured", "Track", true, "Test Song", 0), Entry("uses track title when configured", "Track", true, "Test Song"),
Entry("uses track album when configured", "Album", true, "Test Album", 0), Entry("uses track album when configured", "Album", true, "Test Album"),
Entry("uses track artist when configured", "Artist", true, "Test Artist", 0), Entry("uses track artist when configured", "Artist", true, "Test Artist"),
) )
DescribeTable("custom activity name template", DescribeTable("custom activity name template",
@@ -231,14 +238,13 @@ var _ = Describe("discordPlugin", func() {
pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false) pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false)
pdk.PDKMock.On("GetConfig", activityNameKey).Return("Custom", true) pdk.PDKMock.On("GetConfig", activityNameKey).Return("Custom", true)
pdk.PDKMock.On("GetConfig", activityNameTemplateKey).Return(template, templateExists) pdk.PDKMock.On("GetConfig", activityNameTemplateKey).Return(template, templateExists)
pdk.PDKMock.On("GetConfig", spotifyLinksKey).Return("", false)
// Connect mocks // Connect mocks
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found")) host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found"))
gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`) gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`)
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { gatewayReq := &pdk.HTTPRequest{}
return req.Method == "GET" && req.URL == "https://discord.com/api/gateway" pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://discord.com/api/gateway").Return(gatewayReq).Once()
})).Return(&host.HTTPResponse{StatusCode: 200, Body: gatewayResp}, nil) pdk.PDKMock.On("Send", gatewayReq).Return(pdk.NewStubHTTPResponse(200, nil, gatewayResp)).Once()
host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool { host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool {
return strings.Contains(url, "gateway.discord.gg") return strings.Contains(url, "gateway.discord.gg")
}), mock.Anything, "testuser").Return("testuser", nil) }), mock.Anything, "testuser").Return("testuser", nil)
@@ -252,10 +258,16 @@ var _ = Describe("discordPlugin", func() {
host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil) host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil)
// Image mocks // Image mocks
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil) host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool {
host.CacheMock.On("SetString", discordImageKey, mock.Anything, mock.Anything).Return(nil) return strings.HasPrefix(key, "discord.image.")
})).Return("", false, nil)
host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil)
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil) host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil)
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{}`)}, 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.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil) host.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil)
err := plugin.NowPlaying(scrobbler.NowPlayingRequest{ err := plugin.NowPlaying(scrobbler.NowPlayingRequest{

View File

@@ -2,7 +2,7 @@
"$schema": "https://raw.githubusercontent.com/navidrome/navidrome/refs/heads/master/plugins/manifest-schema.json", "$schema": "https://raw.githubusercontent.com/navidrome/navidrome/refs/heads/master/plugins/manifest-schema.json",
"name": "Discord Rich Presence", "name": "Discord Rich Presence",
"author": "Navidrome Team", "author": "Navidrome Team",
"version": "1.0.0-prerelease", "version": "0.3.0",
"description": "Discord Rich Presence integration for Navidrome", "description": "Discord Rich Presence integration for Navidrome",
"website": "https://github.com/navidrome/discord-rich-presence-plugin", "website": "https://github.com/navidrome/discord-rich-presence-plugin",
"permissions": { "permissions": {
@@ -10,11 +10,10 @@
"reason": "To process scrobbles on behalf of users" "reason": "To process scrobbles on behalf of users"
}, },
"http": { "http": {
"reason": "To communicate with Discord API, image uploads, and ListenBrainz for track resolution", "reason": "To communicate with Discord API for gateway discovery and image uploads",
"requiredHosts": [ "requiredHosts": [
"discord.com", "discord.com",
"uguu.se", "uguu.se"
"labs.api.listenbrainz.org"
] ]
}, },
"websocket": { "websocket": {
@@ -72,12 +71,6 @@
"title": "Upload artwork to uguu.se (enable if Navidrome is not publicly accessible)", "title": "Upload artwork to uguu.se (enable if Navidrome is not publicly accessible)",
"default": false "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": { "users": {
"type": "array", "type": "array",
"title": "User Tokens", "title": "User Tokens",
@@ -142,10 +135,6 @@
"type": "Control", "type": "Control",
"scope": "#/properties/uguuenabled" "scope": "#/properties/uguuenabled"
}, },
{
"type": "Control",
"scope": "#/properties/spotifylinks"
},
{ {
"type": "Control", "type": "Control",
"scope": "#/properties/users", "scope": "#/properties/users",

View File

@@ -1,23 +1,13 @@
package main package main
import ( import (
"strings"
"testing" "testing"
"github.com/navidrome/navidrome/plugins/pdk/go/host"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
"github.com/stretchr/testify/mock"
) )
func TestDiscordPlugin(t *testing.T) { func TestDiscordPlugin(t *testing.T) {
RegisterFailHandler(Fail) RegisterFailHandler(Fail)
RunSpecs(t, "Discord Plugin Main Suite") 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.") })
)

268
rpc.go
View File

@@ -3,11 +3,6 @@
// This file handles all Discord gateway communication including WebSocket connections, // This file handles all Discord gateway communication including WebSocket connections,
// presence updates, and heartbeat management. The discordRPC struct implements WebSocket // presence updates, and heartbeat management. The discordRPC struct implements WebSocket
// callback interfaces and encapsulates all Discord communication logic. // 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 package main
import ( import (
@@ -20,10 +15,16 @@ import (
"github.com/navidrome/navidrome/plugins/pdk/go/websocket" "github.com/navidrome/navidrome/plugins/pdk/go/websocket"
) )
// Image cache TTL constants // Discord WebSocket Gateway constants
const ( const (
imageCacheTTL int64 = 4 * 60 * 60 // 4 hours for track artwork heartbeatOpCode = 1 // Heartbeat operation code
defaultImageCacheTTL int64 = 48 * 60 * 60 // 48 hours for default Navidrome logo gateOpCode = 2 // Identify operation code
presenceOpCode = 3 // Presence update operation code
)
const (
heartbeatInterval = 41 // Heartbeat interval in seconds
defaultImage = "https://i.imgur.com/hb3XPzA.png"
) )
// Scheduler callback payloads for routing // Scheduler callback payloads for routing
@@ -35,99 +36,6 @@ const (
// discordRPC handles Discord gateway communication and implements WebSocket callbacks. // discordRPC handles Discord gateway communication and implements WebSocket callbacks.
type discordRPC struct{} 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
// Discord API field length limits
const (
maxTextLength = 128 // Max characters for text fields (details, state, name, large_text)
maxURLLength = 256 // Max characters for URL fields (details_url, state_url, etc.)
)
// truncateText truncates s to maxTextLength runes, appending "…" if truncated.
func truncateText(s string) string {
runes := []rune(s)
if len(runes) <= maxTextLength {
return s
}
return string(runes[:maxTextLength-1]) + "…"
}
// truncateURL returns s unchanged if within maxURLLength, otherwise returns ""
// (a truncated URL would be broken, so we omit it entirely).
func truncateURL(s string) string {
if len(s) <= maxURLLength {
return s
}
return ""
}
// 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 // WebSocket Callback Implementation
// ============================================================================ // ============================================================================
@@ -155,15 +63,59 @@ func (r *discordRPC) OnClose(input websocket.OnCloseRequest) error {
return nil 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 // Image Processing
// ============================================================================ // ============================================================================
// processImage processes an image URL for Discord. Returns the processed image // processImage processes an image URL for Discord, with fallback to default image.
// string (mp:prefixed) or an error. No fallback logic — the caller handles retries. func (r *discordRPC) processImage(imageURL, clientID, token string, isDefaultImage bool) (string, error) {
func (r *discordRPC) processImage(imageURL, clientID, token string, ttl int64) (string, error) {
if imageURL == "" { if imageURL == "" {
return "", fmt.Errorf("image URL is empty") if isDefaultImage {
return "", fmt.Errorf("default image URL is empty")
}
return r.processImage(defaultImage, clientID, token, true)
} }
if strings.HasPrefix(imageURL, "mp:") { if strings.HasPrefix(imageURL, "mp:") {
@@ -171,7 +123,7 @@ func (r *discordRPC) processImage(imageURL, clientID, token string, ttl int64) (
} }
// Check cache first // Check cache first
cacheKey := "discord.image." + hashKey(imageURL) cacheKey := fmt.Sprintf("discord.image.%x", imageURL)
cachedValue, exists, err := host.CacheGetString(cacheKey) cachedValue, exists, err := host.CacheGetString(cacheKey)
if err == nil && exists { if err == nil && exists {
pdk.Log(pdk.LogDebug, fmt.Sprintf("Cache hit for image URL: %s", imageURL)) pdk.Log(pdk.LogDebug, fmt.Sprintf("Cache hit for image URL: %s", imageURL))
@@ -180,36 +132,50 @@ func (r *discordRPC) processImage(imageURL, clientID, token string, ttl int64) (
// Process via Discord API // Process via Discord API
body := fmt.Sprintf(`{"urls":[%q]}`, imageURL) body := fmt.Sprintf(`{"urls":[%q]}`, imageURL)
resp, err := host.HTTPSend(host.HTTPRequest{ req := pdk.NewHTTPRequest(pdk.MethodPost, fmt.Sprintf("https://discord.com/api/v9/applications/%s/external-assets", clientID))
Method: "POST", req.SetHeader("Authorization", token)
URL: fmt.Sprintf("https://discord.com/api/v9/applications/%s/external-assets", clientID), req.SetHeader("Content-Type", "application/json")
Headers: map[string]string{"Authorization": token, "Content-Type": "application/json"}, req.SetBody([]byte(body))
Body: []byte(body),
}) resp := req.Send()
if err != nil { if resp.Status() >= 400 {
pdk.Log(pdk.LogWarn, fmt.Sprintf("HTTP request failed for image processing: %v", err)) if isDefaultImage {
return "", fmt.Errorf("failed to process image: %w", err) return "", fmt.Errorf("failed to process default image: HTTP %d", resp.Status())
} }
if resp.StatusCode >= 400 { return r.processImage(defaultImage, clientID, token, true)
return "", fmt.Errorf("failed to process image: HTTP %d", resp.StatusCode)
} }
var data []map[string]string var data []map[string]string
if err := json.Unmarshal(resp.Body, &data); err != nil { if err := json.Unmarshal(resp.Body(), &data); err != nil {
return "", fmt.Errorf("failed to unmarshal image response: %w", err) if isDefaultImage {
return "", fmt.Errorf("failed to unmarshal default image response: %w", err)
}
return r.processImage(defaultImage, clientID, token, true)
} }
if len(data) == 0 { if len(data) == 0 {
return "", fmt.Errorf("no data returned for image") if isDefaultImage {
return "", fmt.Errorf("no data returned for default image")
}
return r.processImage(defaultImage, clientID, token, true)
} }
image := data[0]["external_asset_path"] image := data[0]["external_asset_path"]
if image == "" { if image == "" {
return "", fmt.Errorf("empty external_asset_path for image") if isDefaultImage {
return "", fmt.Errorf("empty external_asset_path for default image")
}
return r.processImage(defaultImage, clientID, token, true)
} }
processedImage := fmt.Sprintf("mp:%s", image) 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) _ = host.CacheSetString(cacheKey, processedImage, ttl)
pdk.Log(pdk.LogDebug, fmt.Sprintf("Cached processed image URL for %s (TTL: %ds)", imageURL, ttl)) pdk.Log(pdk.LogDebug, fmt.Sprintf("Cached processed image URL for %s (TTL: %ds)", imageURL, ttl))
@@ -224,50 +190,14 @@ func (r *discordRPC) processImage(imageURL, clientID, token string, ttl int64) (
func (r *discordRPC) sendActivity(clientID, username, token string, data activity) error { 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)) pdk.Log(pdk.LogInfo, fmt.Sprintf("Sending activity for user %s: %s - %s", username, data.Details, data.State))
// Truncate text fields to Discord's 128-character limit processedImage, err := r.processImage(data.Assets.LargeImage, clientID, token, false)
data.Name = truncateText(data.Name)
data.Details = truncateText(data.Details)
data.State = truncateText(data.State)
data.Assets.LargeText = truncateText(data.Assets.LargeText)
// Omit URLs that exceed Discord's 256-character limit
data.DetailsURL = truncateURL(data.DetailsURL)
data.StateURL = truncateURL(data.StateURL)
data.Assets.LargeURL = truncateURL(data.Assets.LargeURL)
data.Assets.SmallURL = truncateURL(data.Assets.SmallURL)
// Try track artwork first, fall back to Navidrome logo
usingDefaultImage := false
processedImage, err := r.processImage(data.Assets.LargeImage, clientID, token, imageCacheTTL)
if err != nil { if err != nil {
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to process track image for user %s: %v, falling back to default", username, err)) pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to process image for user %s, continuing without image: %v", username, err))
processedImage, err = r.processImage(navidromeLogoURL, clientID, token, defaultImageCacheTTL) data.Assets.LargeImage = ""
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 { } else {
data.Assets.LargeImage = processedImage 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{ presence := presencePayload{
Activities: []activity{data}, Activities: []activity{data},
Status: "dnd", Status: "dnd",
@@ -306,20 +236,14 @@ func (r *discordRPC) sendMessage(username string, opCode int, payload any) error
// getDiscordGateway retrieves the Discord gateway URL. // getDiscordGateway retrieves the Discord gateway URL.
func (r *discordRPC) getDiscordGateway() (string, error) { func (r *discordRPC) getDiscordGateway() (string, error) {
resp, err := host.HTTPSend(host.HTTPRequest{ req := pdk.NewHTTPRequest(pdk.MethodGet, "https://discord.com/api/gateway")
Method: "GET", resp := req.Send()
URL: "https://discord.com/api/gateway", if resp.Status() != 200 {
}) return "", fmt.Errorf("failed to get Discord gateway: HTTP %d", resp.Status())
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 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 "", fmt.Errorf("failed to parse Discord gateway response: %w", err)
} }
return result["url"], nil return result["url"], nil

View File

@@ -1,7 +1,6 @@
package main package main
import ( import (
"encoding/json"
"errors" "errors"
"strings" "strings"
@@ -26,8 +25,6 @@ var _ = Describe("discordRPC", func() {
host.WebSocketMock.Calls = nil host.WebSocketMock.Calls = nil
host.SchedulerMock.ExpectedCalls = nil host.SchedulerMock.ExpectedCalls = nil
host.SchedulerMock.Calls = nil host.SchedulerMock.Calls = nil
host.HTTPMock.ExpectedCalls = nil
host.HTTPMock.Calls = nil
}) })
Describe("sendMessage", func() { Describe("sendMessage", func() {
@@ -84,9 +81,9 @@ var _ = Describe("discordRPC", func() {
// Mock HTTP GET request for gateway discovery // Mock HTTP GET request for gateway discovery
gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`) gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`)
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { httpReq := &pdk.HTTPRequest{}
return req.Method == "GET" && req.URL == "https://discord.com/api/gateway" pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://discord.com/api/gateway").Return(httpReq)
})).Return(&host.HTTPResponse{StatusCode: 200, Body: gatewayResp}, nil) pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, gatewayResp))
// Mock WebSocket connection // Mock WebSocket connection
host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool { host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool {
@@ -205,7 +202,7 @@ var _ = Describe("discordRPC", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
err := r.OnBinaryMessage(websocket.OnBinaryMessageRequest{ err := r.OnBinaryMessage(websocket.OnBinaryMessageRequest{
ConnectionID: "testuser", ConnectionID: "testuser",
Data: []byte("AQID"), // base64 encoded [0x01, 0x02, 0x03] Data: "AQID", // base64 encoded [0x01, 0x02, 0x03]
}) })
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
}) })
@@ -235,103 +232,26 @@ 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() { Describe("sendActivity", func() {
BeforeEach(func() { BeforeEach(func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() 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 with track artwork and SmallImage overlay", func() { It("sends activity update to Discord", 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 { host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
return strings.Contains(msg, `"op":3`) && return strings.Contains(msg, `"op":3`) &&
strings.Contains(msg, `"large_image":"mp:external/art"`) && strings.Contains(msg, `"name":"Test Song"`) &&
strings.Contains(msg, `"small_image":"mp:external/art"`) && strings.Contains(msg, `"state":"Test Artist"`)
strings.Contains(msg, `"small_text":"Navidrome"`)
})).Return(nil) })).Return(nil)
err := r.sendActivity("client123", "testuser", "token123", activity{ err := r.sendActivity("client123", "testuser", "token123", activity{
@@ -340,159 +260,6 @@ var _ = Describe("discordRPC", func() {
Type: 2, Type: 2,
State: "Test Artist", State: "Test Artist",
Details: "Test Album", 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())
})
It("truncates long text fields and omits long URLs", func() {
host.CacheMock.On("GetString", discordImageKey).Return("mp:cached/art", true, nil).Once()
host.CacheMock.On("GetString", discordImageKey).Return("mp:cached/logo", true, nil).Once()
longName := strings.Repeat("N", 200)
longTitle := strings.Repeat("T", 200)
longArtist := strings.Repeat("A", 200)
longAlbum := strings.Repeat("B", 200)
longURL := "https://example.com/" + strings.Repeat("x", 237)
truncatedName := strings.Repeat("N", 127) + "…"
truncatedTitle := strings.Repeat("T", 127) + "…"
truncatedArtist := strings.Repeat("A", 127) + "…"
truncatedAlbum := strings.Repeat("B", 127) + "…"
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
var message struct {
D json.RawMessage `json:"d"`
}
if err := json.Unmarshal([]byte(msg), &message); err != nil {
return false
}
var presence presencePayload
if err := json.Unmarshal(message.D, &presence); err != nil {
return false
}
if len(presence.Activities) != 1 {
return false
}
act := presence.Activities[0]
return act.Name == truncatedName &&
act.Details == truncatedTitle &&
act.State == truncatedArtist &&
act.Assets.LargeText == truncatedAlbum &&
act.DetailsURL == "" &&
act.StateURL == "" &&
act.Assets.LargeURL == "" &&
act.Assets.SmallURL == ""
})).Return(nil)
err := r.sendActivity("client123", "testuser", "token123", activity{
Application: "client123",
Name: longName,
Type: 2,
Details: longTitle,
DetailsURL: longURL,
State: longArtist,
StateURL: longURL,
Assets: activityAssets{
LargeImage: "https://example.com/art.jpg",
LargeText: longAlbum,
LargeURL: longURL,
SmallImage: navidromeLogoURL,
SmallText: "Navidrome",
SmallURL: longURL,
},
}) })
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
}) })
@@ -509,55 +276,4 @@ var _ = Describe("discordRPC", func() {
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
}) })
}) })
Describe("truncateText", func() {
It("returns short strings unchanged", func() {
Expect(truncateText("hello")).To(Equal("hello"))
})
It("returns exactly 128-char strings unchanged", func() {
s := strings.Repeat("a", 128)
Expect(truncateText(s)).To(Equal(s))
})
It("truncates strings over 128 chars to 127 + ellipsis", func() {
s := strings.Repeat("a", 200)
result := truncateText(s)
Expect([]rune(result)).To(HaveLen(128))
Expect(result).To(HaveSuffix("…"))
})
It("handles multi-byte characters correctly", func() {
// 130 Japanese characters — each is one rune but 3 bytes
s := strings.Repeat("あ", 130)
result := truncateText(s)
runes := []rune(result)
Expect(runes).To(HaveLen(128))
Expect(string(runes[127])).To(Equal("…"))
})
It("returns empty string unchanged", func() {
Expect(truncateText("")).To(Equal(""))
})
})
Describe("truncateURL", func() {
It("returns short URLs unchanged", func() {
Expect(truncateURL("https://example.com")).To(Equal("https://example.com"))
})
It("returns exactly 256-char URLs unchanged", func() {
u := "https://example.com/" + strings.Repeat("a", 236)
Expect(truncateURL(u)).To(Equal(u))
})
It("returns empty string for URLs over 256 chars", func() {
u := "https://example.com/" + strings.Repeat("a", 237)
Expect(truncateURL(u)).To(Equal(""))
})
It("returns empty string unchanged", func() {
Expect(truncateURL("")).To(Equal(""))
})
})
}) })

View File

@@ -1,180 +0,0 @@
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
}

View File

@@ -1,219 +0,0 @@
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"))
})
})
})