Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
414021f471
|
|||
|
24fb4cf752
|
|||
|
|
9d9dce052a | ||
|
|
c7c484c714 | ||
|
|
4600172dbf | ||
|
|
3ce0277ecb | ||
|
|
6d7448f3ee | ||
|
|
c22b950be3 | ||
|
|
f96884e3e5 | ||
|
|
1552322429 | ||
|
|
8d07bc6120 | ||
|
|
1a236fd00f | ||
|
|
223ebf0539 | ||
|
|
05714ace50 |
BIN
.github/screenshot.png
vendored
BIN
.github/screenshot.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 37 KiB |
14
.github/workflows/build.yml
vendored
14
.github/workflows/build.yml
vendored
@@ -30,6 +30,20 @@ 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
|
||||||
|
|
||||||
|
|||||||
24
.github/workflows/create-release.yml
vendored
24
.github/workflows/create-release.yml
vendored
@@ -7,6 +7,11 @@ 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
|
||||||
@@ -20,17 +25,23 @@ 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"
|
||||||
@@ -46,14 +57,10 @@ 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"
|
||||||
@@ -74,7 +81,8 @@ jobs:
|
|||||||
- name: Create release
|
- name: Create release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: v${{ inputs.version }}
|
tag_name: v${{ env.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
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,3 +1,5 @@
|
|||||||
*.wasm
|
*.wasm
|
||||||
*.ndp
|
*.ndp
|
||||||
tmp
|
tmp
|
||||||
|
discord-rich-presence
|
||||||
|
.DS_Store
|
||||||
|
|||||||
14
Makefile
14
Makefile
@@ -3,12 +3,18 @@ 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:
|
||||||
tinygo build -opt=2 -scheduler=none -no-debug -o $(WASM_FILE) -target wasi -buildmode=c-shared .
|
ifdef TINYGO
|
||||||
|
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
|
||||||
@@ -17,7 +23,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"; exit 1; fi
|
@if [[ ! "${V}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$$ ]]; then echo "Usage: make release V=X.X.X [PRE=true]"; exit 1; fi
|
||||||
gh workflow run create-release.yml -f version=${V}
|
gh workflow run create-release.yml -f version=${V} -f prerelease=$(if $(filter true,$(PRE)),true,false)
|
||||||
@echo "Release v${V} workflow triggered. Check progress: gh run list --workflow=create-release.yml"
|
@echo "Release v${V}$(if $(filter true,$(PRE)),-prerelease,) workflow triggered. Check progress: gh run list --workflow=create-release.yml"
|
||||||
.PHONY: release
|
.PHONY: release
|
||||||
|
|||||||
145
README.md
145
README.md
@@ -3,6 +3,8 @@
|
|||||||
[](https://github.com/navidrome/discord-rich-presence-plugin/actions/workflows/build.yml)
|
[](https://github.com/navidrome/discord-rich-presence-plugin/actions/workflows/build.yml)
|
||||||
[](https://github.com/navidrome/discord-rich-presence-plugin/releases/latest/download/discord-rich-presence.ndp)
|
[](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.
|
||||||
@@ -14,13 +16,16 @@ 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 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">
|
<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">
|
||||||
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
@@ -43,7 +48,10 @@ 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
|
||||||
@@ -89,6 +97,43 @@ 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
|
||||||
@@ -103,14 +148,14 @@ The plugin implements three Navidrome capabilities:
|
|||||||
|
|
||||||
### Host Services
|
### Host Services
|
||||||
|
|
||||||
| Service | Usage |
|
| Service | Usage |
|
||||||
|-----------------|---------------------------------------------------------------------|
|
|-----------------|------------------------------------------------------------------------------------------------------|
|
||||||
| **HTTP** | Discord API calls (gateway discovery, external assets registration) |
|
| **HTTP** | Discord API calls (gateway discovery, external assets registration), ListenBrainz Spotify resolution |
|
||||||
| **WebSocket** | Persistent connection to Discord gateway |
|
| **WebSocket** | Persistent connection to Discord gateway |
|
||||||
| **Cache** | Sequence numbers, processed image URLs |
|
| **Cache** | Sequence numbers, processed image URLs, resolved Spotify 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
|
||||||
|
|
||||||
@@ -140,68 +185,30 @@ Discord requires images to be registered via their external assets API. The plug
|
|||||||
|
|
||||||
**For non-public Navidrome instances**: If your server isn't publicly accessible (e.g., behind a VPN or firewall), enable the "Upload to uguu.se" option. This uploads artwork to a temporary file host so Discord can display it.
|
**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 |
|
| [main.go](main.go) | Plugin entry point, scrobbler and scheduler implementations, Spotify URL resolution |
|
||||||
| [rpc.go](rpc.go) | Discord gateway communication, WebSocket handling, activity management |
|
| [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
|
||||||
|
|
||||||
|
|||||||
20
coverart.go
20
coverart.go
@@ -84,17 +84,21 @@ func uploadToUguu(imageData []byte, contentType string) (string, error) {
|
|||||||
body = append(body, imageData...)
|
body = append(body, 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))...)
|
||||||
|
|
||||||
req := pdk.NewHTTPRequest(pdk.MethodPost, "https://uguu.se/upload")
|
resp, err := host.HTTPSend(host.HTTPRequest{
|
||||||
req.SetHeader("Content-Type", fmt.Sprintf("multipart/form-data; boundary=%s", boundary))
|
Method: "POST",
|
||||||
req.SetBody(body)
|
URL: "https://uguu.se/upload",
|
||||||
|
Headers: map[string]string{"Content-Type": fmt.Sprintf("multipart/form-data; boundary=%s", boundary)},
|
||||||
resp := req.Send()
|
Body: body,
|
||||||
if resp.Status() >= 400 {
|
})
|
||||||
return "", fmt.Errorf("uguu.se upload failed: HTTP %d", resp.Status())
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("uguu.se upload failed: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return "", fmt.Errorf("uguu.se upload failed: HTTP %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
var result uguuResponse
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ 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()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -71,10 +73,9 @@ var _ = Describe("getImageURL", func() {
|
|||||||
Return("image/jpeg", imageData, nil)
|
Return("image/jpeg", imageData, nil)
|
||||||
|
|
||||||
// Mock uguu.se HTTP upload
|
// Mock uguu.se HTTP upload
|
||||||
uguuReq := &pdk.HTTPRequest{}
|
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||||
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, "https://uguu.se/upload").Return(uguuReq)
|
return req.URL == "https://uguu.se/upload"
|
||||||
pdk.PDKMock.On("Send", uguuReq).Return(pdk.NewStubHTTPResponse(200, nil,
|
})).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{"success":true,"files":[{"url":"https://a.uguu.se/uploaded.jpg"}]}`)}, 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)
|
||||||
@@ -98,9 +99,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)
|
||||||
|
|
||||||
uguuReq := &pdk.HTTPRequest{}
|
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||||
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, "https://uguu.se/upload").Return(uguuReq)
|
return req.URL == "https://uguu.se/upload"
|
||||||
pdk.PDKMock.On("Send", uguuReq).Return(pdk.NewStubHTTPResponse(500, nil, []byte(`{"success":false}`)))
|
})).Return(&host.HTTPResponse{StatusCode: 500, Body: []byte(`{"success":false}`)}, nil)
|
||||||
|
|
||||||
url := getImageURL("testuser", "track1")
|
url := getImageURL("testuser", "track1")
|
||||||
Expect(url).To(BeEmpty())
|
Expect(url).To(BeEmpty())
|
||||||
|
|||||||
20
go.mod
20
go.mod
@@ -1,9 +1,9 @@
|
|||||||
module discord-rich-presence
|
module discord-rich-presence
|
||||||
|
|
||||||
go 1.25
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260207182358-29f98b889b11
|
github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260303204839-f03ca44a8ec4
|
||||||
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,19 +15,21 @@ 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-20260202012954-cb029daf43ef // indirect
|
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // 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.2 // indirect
|
github.com/stretchr/objx v0.5.3 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/mod v0.32.0 // indirect
|
golang.org/x/mod v0.33.0 // indirect
|
||||||
golang.org/x/net v0.49.0 // indirect
|
golang.org/x/net v0.51.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.33.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
golang.org/x/tools v0.41.0 // indirect
|
golang.org/x/tools v0.42.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
43
go.sum
@@ -14,24 +14,27 @@ 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.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 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-20260202012954-cb029daf43ef h1:xpF9fUHpoIrrjX24DURVKiwHcFpw19ndIs+FwTSMbno=
|
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
|
||||||
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/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-20260207182358-29f98b889b11 h1:VE4bqzkS6apWDtco9hAGdThFttjbYoLR0DEILAGDyyc=
|
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/go.mod h1:HijQ0Z0OeEa6LIwUJh6H9WqAptye096jHazmKXf+YV4=
|
github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260303204839-f03ca44a8ec4/go.mod h1:5aedoevIXlwUFuR7kbd/WkjaiLg87D3XUFRGIwDBroo=
|
||||||
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=
|
||||||
@@ -40,8 +43,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.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 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=
|
||||||
@@ -54,22 +57,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.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.19.0 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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 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-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 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=
|
||||||
|
|||||||
56
main.go
56
main.go
@@ -25,9 +25,19 @@ import (
|
|||||||
|
|
||||||
// Configuration keys
|
// Configuration keys
|
||||||
const (
|
const (
|
||||||
clientIDKey = "clientid"
|
clientIDKey = "clientid"
|
||||||
usersKey = "users"
|
usersKey = "users"
|
||||||
activityNameKey = "activityname"
|
activityNameKey = "activityname"
|
||||||
|
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
|
||||||
@@ -36,6 +46,7 @@ const (
|
|||||||
activityNameTrack = "Track"
|
activityNameTrack = "Track"
|
||||||
activityNameArtist = "Artist"
|
activityNameArtist = "Artist"
|
||||||
activityNameAlbum = "Album"
|
activityNameAlbum = "Album"
|
||||||
|
activityNameCustom = "Custom"
|
||||||
)
|
)
|
||||||
|
|
||||||
// userToken represents a user-token mapping from the config
|
// userToken represents a user-token mapping from the config
|
||||||
@@ -147,23 +158,48 @@ 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:
|
||||||
|
template, _ := pdk.GetConfig(activityNameTemplateKey)
|
||||||
|
if template != "" {
|
||||||
|
r := strings.NewReplacer(
|
||||||
|
"{track}", input.Track.Title,
|
||||||
|
"{artist}", input.Track.Artist,
|
||||||
|
"{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,
|
||||||
State: input.Track.Artist,
|
DetailsURL: spotifyURL,
|
||||||
|
State: input.Track.Artist,
|
||||||
|
StateURL: artistSearchURL,
|
||||||
|
StatusDisplayType: statusDisplayType,
|
||||||
Timestamps: activityTimestamps{
|
Timestamps: activityTimestamps{
|
||||||
Start: startTime,
|
Start: startTime,
|
||||||
End: endTime,
|
End: endTime,
|
||||||
@@ -171,6 +207,10 @@ 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)
|
||||||
|
|||||||
113
main_test.go
113
main_test.go
@@ -33,6 +33,8 @@ 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() {
|
||||||
@@ -121,15 +123,16 @@ 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"}`)
|
||||||
gatewayReq := &pdk.HTTPRequest{}
|
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||||
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://discord.com/api/gateway").Return(gatewayReq).Once()
|
return req.Method == "GET" && req.URL == "https://discord.com/api/gateway"
|
||||||
pdk.PDKMock.On("Send", gatewayReq).Return(pdk.NewStubHTTPResponse(200, nil, gatewayResp)).Once()
|
})).Return(&host.HTTPResponse{StatusCode: 200, Body: gatewayResp}, nil)
|
||||||
|
|
||||||
// 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 {
|
||||||
@@ -141,19 +144,13 @@ 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)
|
||||||
|
|
||||||
// Image mocks - cache miss, will make HTTP request to Discord
|
// Cache mocks (Discord image processing)
|
||||||
host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool {
|
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
|
||||||
return strings.HasPrefix(key, "discord.image.")
|
host.CacheMock.On("SetString", discordImageKey, mock.Anything, mock.Anything).Return(nil)
|
||||||
})).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 request for Discord external assets API
|
// Mock HTTP POST requests (Discord external assets API)
|
||||||
assetsReq := &pdk.HTTPRequest{}
|
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{}`)}, nil)
|
||||||
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)
|
||||||
@@ -173,18 +170,75 @@ var _ = Describe("discordPlugin", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
DescribeTable("activity name configuration",
|
DescribeTable("activity name configuration",
|
||||||
func(configValue string, configExists bool, expectedName string) {
|
func(configValue string, configExists bool, expectedName string, expectedDisplayType int) {
|
||||||
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true)
|
pdk.PDKMock.On("GetConfig", 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"}`)
|
||||||
gatewayReq := &pdk.HTTPRequest{}
|
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||||
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://discord.com/api/gateway").Return(gatewayReq).Once()
|
return req.Method == "GET" && req.URL == "https://discord.com/api/gateway"
|
||||||
pdk.PDKMock.On("Send", gatewayReq).Return(pdk.NewStubHTTPResponse(200, nil, gatewayResp)).Once()
|
})).Return(&host.HTTPResponse{StatusCode: 200, Body: gatewayResp}, nil)
|
||||||
|
host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool {
|
||||||
|
return strings.Contains(url, "gateway.discord.gg")
|
||||||
|
}), mock.Anything, "testuser").Return("testuser", nil)
|
||||||
|
|
||||||
|
// Capture the activity payload sent to Discord
|
||||||
|
var sentPayload string
|
||||||
|
host.WebSocketMock.On("SendText", "testuser", mock.Anything).Run(func(args mock.Arguments) {
|
||||||
|
sentPayload = args.Get(1).(string)
|
||||||
|
}).Return(nil)
|
||||||
|
host.SchedulerMock.On("ScheduleRecurring", mock.Anything, payloadHeartbeat, "testuser").Return("testuser", nil)
|
||||||
|
host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil)
|
||||||
|
|
||||||
|
// Cache mocks (Discord image processing)
|
||||||
|
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
|
||||||
|
host.CacheMock.On("SetString", discordImageKey, mock.Anything, mock.Anything).Return(nil)
|
||||||
|
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil)
|
||||||
|
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{}`)}, nil)
|
||||||
|
host.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil)
|
||||||
|
|
||||||
|
err := plugin.NowPlaying(scrobbler.NowPlayingRequest{
|
||||||
|
Username: "testuser",
|
||||||
|
Position: 10,
|
||||||
|
Track: scrobbler.TrackInfo{
|
||||||
|
ID: "track1",
|
||||||
|
Title: "Test Song",
|
||||||
|
Artist: "Test Artist",
|
||||||
|
Album: "Test Album",
|
||||||
|
Duration: 180,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(sentPayload).To(ContainSubstring(fmt.Sprintf(`"name":"%s"`, expectedName)))
|
||||||
|
Expect(sentPayload).To(ContainSubstring(fmt.Sprintf(`"status_display_type":%d`, expectedDisplayType)))
|
||||||
|
},
|
||||||
|
Entry("defaults to Navidrome when not configured", "", false, "Navidrome", 2),
|
||||||
|
Entry("defaults to Navidrome with explicit default value", "Default", true, "Navidrome", 2),
|
||||||
|
Entry("uses track title when configured", "Track", true, "Test Song", 0),
|
||||||
|
Entry("uses track album when configured", "Album", true, "Test Album", 0),
|
||||||
|
Entry("uses track artist when configured", "Artist", true, "Test Artist", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
DescribeTable("custom activity name template",
|
||||||
|
func(template string, templateExists bool, expectedName string) {
|
||||||
|
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true)
|
||||||
|
pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"testuser","token":"test-token"}]`, true)
|
||||||
|
pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false)
|
||||||
|
pdk.PDKMock.On("GetConfig", activityNameKey).Return("Custom", true)
|
||||||
|
pdk.PDKMock.On("GetConfig", activityNameTemplateKey).Return(template, templateExists)
|
||||||
|
pdk.PDKMock.On("GetConfig", spotifyLinksKey).Return("", false)
|
||||||
|
|
||||||
|
// Connect mocks
|
||||||
|
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found"))
|
||||||
|
gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`)
|
||||||
|
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||||
|
return req.Method == "GET" && req.URL == "https://discord.com/api/gateway"
|
||||||
|
})).Return(&host.HTTPResponse{StatusCode: 200, Body: gatewayResp}, nil)
|
||||||
host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool {
|
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)
|
||||||
@@ -198,16 +252,10 @@ 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", mock.MatchedBy(func(key string) bool {
|
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
|
||||||
return strings.HasPrefix(key, "discord.image.")
|
host.CacheMock.On("SetString", discordImageKey, mock.Anything, mock.Anything).Return(nil)
|
||||||
})).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)
|
||||||
assetsReq := &pdk.HTTPRequest{}
|
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{}`)}, nil)
|
||||||
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{
|
||||||
@@ -224,11 +272,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)))
|
||||||
},
|
},
|
||||||
Entry("defaults to Navidrome when not configured", "", false, "Navidrome"),
|
Entry("uses custom template with all placeholders", "{artist} - {track} ({album})", true, "Test Artist - Test Song (Test Album)"),
|
||||||
Entry("defaults to Navidrome with explicit default value", "Default", true, "Navidrome"),
|
Entry("uses custom template with only track", "{track}", true, "Test Song"),
|
||||||
Entry("uses track title when configured", "Track", true, "Test Song"),
|
Entry("uses custom template with only artist", "{artist}", true, "Test Artist"),
|
||||||
Entry("uses track album when configured", "Album", true, "Test Album"),
|
Entry("uses custom template with only album", "{album}", true, "Test Album"),
|
||||||
Entry("uses track artist when configured", "Artist", true, "Test Artist"),
|
Entry("uses custom template with plain text", "Now Playing", true, "Now Playing"),
|
||||||
|
Entry("falls back to Navidrome when template is empty", "", false, "Navidrome"),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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": "0.3.0",
|
"version": "1.0.0-prerelease",
|
||||||
"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,10 +10,11 @@
|
|||||||
"reason": "To process scrobbles on behalf of users"
|
"reason": "To process scrobbles on behalf of users"
|
||||||
},
|
},
|
||||||
"http": {
|
"http": {
|
||||||
"reason": "To communicate with Discord API for gateway discovery and image uploads",
|
"reason": "To communicate with Discord API, image uploads, and ListenBrainz for track resolution",
|
||||||
"requiredHosts": [
|
"requiredHosts": [
|
||||||
"discord.com",
|
"discord.com",
|
||||||
"uguu.se"
|
"uguu.se",
|
||||||
|
"labs.api.listenbrainz.org"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"websocket": {
|
"websocket": {
|
||||||
@@ -55,15 +56,28 @@
|
|||||||
"Default",
|
"Default",
|
||||||
"Track",
|
"Track",
|
||||||
"Album",
|
"Album",
|
||||||
"Artist"
|
"Artist",
|
||||||
|
"Custom"
|
||||||
],
|
],
|
||||||
"default": "Default"
|
"default": "Default"
|
||||||
},
|
},
|
||||||
|
"activitynametemplate": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Custom Activity Name Template",
|
||||||
|
"description": "Template for the activity name. Available placeholders: {track}, {artist}, {album}",
|
||||||
|
"default": "{artist} - {track}"
|
||||||
|
},
|
||||||
"uguuenabled": {
|
"uguuenabled": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"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",
|
||||||
@@ -111,10 +125,27 @@
|
|||||||
"format": "radio"
|
"format": "radio"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "Control",
|
||||||
|
"scope": "#/properties/activitynametemplate",
|
||||||
|
"rule": {
|
||||||
|
"effect": "SHOW",
|
||||||
|
"condition": {
|
||||||
|
"scope": "#/properties/activityname",
|
||||||
|
"schema": {
|
||||||
|
"const": "Custom"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"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",
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
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
268
rpc.go
@@ -3,6 +3,11 @@
|
|||||||
// 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 (
|
||||||
@@ -15,16 +20,10 @@ import (
|
|||||||
"github.com/navidrome/navidrome/plugins/pdk/go/websocket"
|
"github.com/navidrome/navidrome/plugins/pdk/go/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Discord WebSocket Gateway constants
|
// Image cache TTL constants
|
||||||
const (
|
const (
|
||||||
heartbeatOpCode = 1 // Heartbeat operation code
|
imageCacheTTL int64 = 4 * 60 * 60 // 4 hours for track artwork
|
||||||
gateOpCode = 2 // Identify operation code
|
defaultImageCacheTTL int64 = 48 * 60 * 60 // 48 hours for default Navidrome logo
|
||||||
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
|
||||||
@@ -36,6 +35,99 @@ 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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -63,59 +155,15 @@ 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, with fallback to default image.
|
// processImage processes an image URL for Discord. Returns the processed image
|
||||||
func (r *discordRPC) processImage(imageURL, clientID, token string, isDefaultImage bool) (string, error) {
|
// string (mp:prefixed) or an error. No fallback logic — the caller handles retries.
|
||||||
|
func (r *discordRPC) processImage(imageURL, clientID, token string, ttl int64) (string, error) {
|
||||||
if imageURL == "" {
|
if imageURL == "" {
|
||||||
if isDefaultImage {
|
return "", fmt.Errorf("image URL is empty")
|
||||||
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:") {
|
||||||
@@ -123,7 +171,7 @@ func (r *discordRPC) processImage(imageURL, clientID, token string, isDefaultIma
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check cache first
|
// Check cache first
|
||||||
cacheKey := fmt.Sprintf("discord.image.%x", imageURL)
|
cacheKey := "discord.image." + hashKey(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))
|
||||||
@@ -132,50 +180,36 @@ func (r *discordRPC) processImage(imageURL, clientID, token string, isDefaultIma
|
|||||||
|
|
||||||
// Process via Discord API
|
// Process via Discord API
|
||||||
body := fmt.Sprintf(`{"urls":[%q]}`, imageURL)
|
body := fmt.Sprintf(`{"urls":[%q]}`, imageURL)
|
||||||
req := pdk.NewHTTPRequest(pdk.MethodPost, fmt.Sprintf("https://discord.com/api/v9/applications/%s/external-assets", clientID))
|
resp, err := host.HTTPSend(host.HTTPRequest{
|
||||||
req.SetHeader("Authorization", token)
|
Method: "POST",
|
||||||
req.SetHeader("Content-Type", "application/json")
|
URL: fmt.Sprintf("https://discord.com/api/v9/applications/%s/external-assets", clientID),
|
||||||
req.SetBody([]byte(body))
|
Headers: map[string]string{"Authorization": token, "Content-Type": "application/json"},
|
||||||
|
Body: []byte(body),
|
||||||
resp := req.Send()
|
})
|
||||||
if resp.Status() >= 400 {
|
if err != nil {
|
||||||
if isDefaultImage {
|
pdk.Log(pdk.LogWarn, fmt.Sprintf("HTTP request failed for image processing: %v", err))
|
||||||
return "", fmt.Errorf("failed to process default image: HTTP %d", resp.Status())
|
return "", fmt.Errorf("failed to process image: %w", err)
|
||||||
}
|
}
|
||||||
return r.processImage(defaultImage, clientID, token, true)
|
if resp.StatusCode >= 400 {
|
||||||
|
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 {
|
||||||
if isDefaultImage {
|
return "", fmt.Errorf("failed to unmarshal image response: %w", err)
|
||||||
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 {
|
||||||
if isDefaultImage {
|
return "", fmt.Errorf("no data returned for image")
|
||||||
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 == "" {
|
||||||
if isDefaultImage {
|
return "", fmt.Errorf("empty external_asset_path for image")
|
||||||
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))
|
||||||
|
|
||||||
@@ -190,14 +224,50 @@ func (r *discordRPC) processImage(imageURL, clientID, token string, isDefaultIma
|
|||||||
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))
|
||||||
|
|
||||||
processedImage, err := r.processImage(data.Assets.LargeImage, clientID, token, false)
|
// Truncate text fields to Discord's 128-character limit
|
||||||
|
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 image for user %s, continuing without image: %v", username, err))
|
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to process track image for user %s: %v, falling back to default", username, err))
|
||||||
data.Assets.LargeImage = ""
|
processedImage, err = r.processImage(navidromeLogoURL, clientID, token, defaultImageCacheTTL)
|
||||||
|
if err != nil {
|
||||||
|
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to process default image for user %s: %v, continuing without image", username, err))
|
||||||
|
data.Assets.LargeImage = ""
|
||||||
|
} else {
|
||||||
|
data.Assets.LargeImage = processedImage
|
||||||
|
usingDefaultImage = true
|
||||||
|
}
|
||||||
} else {
|
} 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",
|
||||||
@@ -236,14 +306,20 @@ 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) {
|
||||||
req := pdk.NewHTTPRequest(pdk.MethodGet, "https://discord.com/api/gateway")
|
resp, err := host.HTTPSend(host.HTTPRequest{
|
||||||
resp := req.Send()
|
Method: "GET",
|
||||||
if resp.Status() != 200 {
|
URL: "https://discord.com/api/gateway",
|
||||||
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
|
||||||
|
|||||||
318
rpc_test.go
318
rpc_test.go
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -25,6 +26,8 @@ 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() {
|
||||||
@@ -81,9 +84,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"}`)
|
||||||
httpReq := &pdk.HTTPRequest{}
|
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||||
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://discord.com/api/gateway").Return(httpReq)
|
return req.Method == "GET" && req.URL == "https://discord.com/api/gateway"
|
||||||
pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, gatewayResp))
|
})).Return(&host.HTTPResponse{StatusCode: 200, Body: gatewayResp}, nil)
|
||||||
|
|
||||||
// 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 {
|
||||||
@@ -202,7 +205,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: "AQID", // base64 encoded [0x01, 0x02, 0x03]
|
Data: []byte("AQID"), // base64 encoded [0x01, 0x02, 0x03]
|
||||||
})
|
})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
})
|
})
|
||||||
@@ -232,26 +235,103 @@ var _ = Describe("discordRPC", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("processImage", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error for empty URL", func() {
|
||||||
|
_, err := r.processImage("", "client123", "token123", imageCacheTTL)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("image URL is empty"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns mp: prefixed URL as-is", func() {
|
||||||
|
result, err := r.processImage("mp:external/abc123", "client123", "token123", imageCacheTTL)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(result).To(Equal("mp:external/abc123"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns cached value on cache hit", func() {
|
||||||
|
host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool {
|
||||||
|
return strings.HasPrefix(key, "discord.image.")
|
||||||
|
})).Return("mp:cached/image", true, nil)
|
||||||
|
|
||||||
|
result, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(result).To(Equal("mp:cached/image"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("processes image via Discord API and caches result", func() {
|
||||||
|
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
|
||||||
|
host.CacheMock.On("SetString", discordImageKey, mock.MatchedBy(func(val string) bool {
|
||||||
|
return val == "mp:external/new-asset"
|
||||||
|
}), int64(imageCacheTTL)).Return(nil)
|
||||||
|
|
||||||
|
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[{"external_asset_path":"external/new-asset"}]`)}, nil)
|
||||||
|
|
||||||
|
result, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(result).To(Equal("mp:external/new-asset"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error on HTTP failure", func() {
|
||||||
|
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
|
||||||
|
|
||||||
|
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 500, Body: []byte(`error`)}, nil)
|
||||||
|
|
||||||
|
_, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("HTTP 500"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error on unmarshal failure", func() {
|
||||||
|
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
|
||||||
|
|
||||||
|
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{"not":"an-array"}`)}, nil)
|
||||||
|
|
||||||
|
_, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("failed to unmarshal"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error on empty response array", func() {
|
||||||
|
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
|
||||||
|
|
||||||
|
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[]`)}, nil)
|
||||||
|
|
||||||
|
_, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("no data returned"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error on empty external_asset_path", func() {
|
||||||
|
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
|
||||||
|
|
||||||
|
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[{"external_asset_path":""}]`)}, nil)
|
||||||
|
|
||||||
|
_, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("empty external_asset_path"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
Describe("sendActivity", func() {
|
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 update to Discord", func() {
|
It("sends activity with track artwork and SmallImage overlay", func() {
|
||||||
|
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
|
||||||
|
host.CacheMock.On("SetString", discordImageKey, mock.Anything, mock.Anything).Return(nil)
|
||||||
|
|
||||||
|
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[{"external_asset_path":"external/art"}]`)}, nil)
|
||||||
|
|
||||||
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
|
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, `"name":"Test Song"`) &&
|
strings.Contains(msg, `"large_image":"mp:external/art"`) &&
|
||||||
strings.Contains(msg, `"state":"Test Artist"`)
|
strings.Contains(msg, `"small_image":"mp:external/art"`) &&
|
||||||
|
strings.Contains(msg, `"small_text":"Navidrome"`)
|
||||||
})).Return(nil)
|
})).Return(nil)
|
||||||
|
|
||||||
err := r.sendActivity("client123", "testuser", "token123", activity{
|
err := r.sendActivity("client123", "testuser", "token123", activity{
|
||||||
@@ -260,6 +340,159 @@ 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())
|
||||||
})
|
})
|
||||||
@@ -276,4 +509,55 @@ 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(""))
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
180
spotify.go
Normal file
180
spotify.go
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||||
|
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||||
|
"github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hashKey returns a hex-encoded FNV-1a hash of s, for use as a cache key suffix.
|
||||||
|
func hashKey(s string) string {
|
||||||
|
const offset64 uint64 = 14695981039346656037
|
||||||
|
const prime64 uint64 = 1099511628211
|
||||||
|
h := offset64
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
h ^= uint64(s[i])
|
||||||
|
h *= prime64
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%016x", h)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
spotifyCacheTTLHit int64 = 30 * 24 * 60 * 60 // 30 days for resolved track IDs
|
||||||
|
spotifyCacheTTLMiss int64 = 4 * 60 * 60 // 4 hours for misses (retry later)
|
||||||
|
)
|
||||||
|
|
||||||
|
// listenBrainzResult captures the relevant field from ListenBrainz Labs JSON responses.
|
||||||
|
// The API returns spotify_track_ids as an array of strings.
|
||||||
|
type listenBrainzResult struct {
|
||||||
|
SpotifyTrackIDs []string `json:"spotify_track_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// spotifySearchURL builds a Spotify search URL from one or more terms.
|
||||||
|
// Empty terms are ignored. Returns "" if all terms are empty.
|
||||||
|
func spotifySearchURL(terms ...string) string {
|
||||||
|
query := strings.TrimSpace(strings.Join(terms, " "))
|
||||||
|
if query == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "https://open.spotify.com/search/" + url.PathEscape(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
// spotifyCacheKey returns a deterministic cache key for a track's Spotify URL.
|
||||||
|
func spotifyCacheKey(artist, title, album string) string {
|
||||||
|
return "spotify.url." + hashKey(strings.ToLower(artist)+"\x00"+strings.ToLower(title)+"\x00"+strings.ToLower(album))
|
||||||
|
}
|
||||||
|
|
||||||
|
// trySpotifyFromMBID calls the ListenBrainz spotify-id-from-mbid endpoint.
|
||||||
|
func trySpotifyFromMBID(mbid string) string {
|
||||||
|
body := fmt.Sprintf(`[{"recording_mbid":%q}]`, mbid)
|
||||||
|
resp, err := host.HTTPSend(host.HTTPRequest{
|
||||||
|
Method: "POST",
|
||||||
|
URL: "https://labs.api.listenbrainz.org/spotify-id-from-mbid/json",
|
||||||
|
Headers: map[string]string{"Content-Type": "application/json"},
|
||||||
|
Body: []byte(body),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
pdk.Log(pdk.LogInfo, fmt.Sprintf("ListenBrainz MBID lookup request failed: %v", err))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz MBID lookup failed: HTTP %d, body=%s", resp.StatusCode, string(resp.Body)))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
id := parseSpotifyID(resp.Body)
|
||||||
|
if id == "" {
|
||||||
|
pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz MBID lookup returned no spotify_track_id for mbid=%s, body=%s", mbid, string(resp.Body)))
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// trySpotifyFromMetadata calls the ListenBrainz spotify-id-from-metadata endpoint.
|
||||||
|
func trySpotifyFromMetadata(artist, title, album string) string {
|
||||||
|
payload := fmt.Sprintf(`[{"artist_name":%q,"track_name":%q,"release_name":%q}]`, artist, title, album)
|
||||||
|
|
||||||
|
pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz metadata request: %s", payload))
|
||||||
|
|
||||||
|
resp, err := host.HTTPSend(host.HTTPRequest{
|
||||||
|
Method: "POST",
|
||||||
|
URL: "https://labs.api.listenbrainz.org/spotify-id-from-metadata/json",
|
||||||
|
Headers: map[string]string{"Content-Type": "application/json"},
|
||||||
|
Body: []byte(payload),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
pdk.Log(pdk.LogInfo, fmt.Sprintf("ListenBrainz metadata lookup request failed: %v", err))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz metadata lookup failed: HTTP %d, body=%s", resp.StatusCode, string(resp.Body)))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz metadata response: HTTP %d, body=%s", resp.StatusCode, string(resp.Body)))
|
||||||
|
id := parseSpotifyID(resp.Body)
|
||||||
|
if id == "" {
|
||||||
|
pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz metadata returned no spotify_track_id for %q - %q", artist, title))
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSpotifyID extracts the first spotify track ID from a ListenBrainz Labs JSON response.
|
||||||
|
// The response is an array of objects with spotify_track_ids arrays; we take the first non-empty ID.
|
||||||
|
func parseSpotifyID(body []byte) string {
|
||||||
|
var results []listenBrainzResult
|
||||||
|
if err := json.Unmarshal(body, &results); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, r := range results {
|
||||||
|
for _, id := range r.SpotifyTrackIDs {
|
||||||
|
if isValidSpotifyID(id) {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidSpotifyID checks that a Spotify track ID is non-empty and contains only base-62 characters.
|
||||||
|
func isValidSpotifyID(id string) bool {
|
||||||
|
if len(id) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := 0; i < len(id); i++ {
|
||||||
|
c := id[i]
|
||||||
|
if !((c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveSpotifyURL resolves a direct Spotify track URL via ListenBrainz Labs,
|
||||||
|
// falling back to a search URL. Results are cached.
|
||||||
|
func resolveSpotifyURL(track scrobbler.TrackInfo) string {
|
||||||
|
var primary string
|
||||||
|
if len(track.Artists) > 0 {
|
||||||
|
primary = track.Artists[0].Name
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheKey := spotifyCacheKey(primary, track.Title, track.Album)
|
||||||
|
|
||||||
|
if cached, exists, err := host.CacheGetString(cacheKey); err == nil && exists {
|
||||||
|
pdk.Log(pdk.LogDebug, fmt.Sprintf("Spotify URL cache hit for %q - %q → %s", primary, track.Title, cached))
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
pdk.Log(pdk.LogDebug, fmt.Sprintf("Resolving Spotify URL for: artist=%q title=%q album=%q mbid=%q", primary, track.Title, track.Album, track.MBZRecordingID))
|
||||||
|
|
||||||
|
// 1. Try MBID lookup (most accurate)
|
||||||
|
if track.MBZRecordingID != "" {
|
||||||
|
if trackID := trySpotifyFromMBID(track.MBZRecordingID); trackID != "" {
|
||||||
|
directURL := "https://open.spotify.com/track/" + trackID
|
||||||
|
_ = host.CacheSetString(cacheKey, directURL, spotifyCacheTTLHit)
|
||||||
|
pdk.Log(pdk.LogInfo, fmt.Sprintf("Resolved Spotify via MBID for %q: %s", track.Title, directURL))
|
||||||
|
return directURL
|
||||||
|
}
|
||||||
|
pdk.Log(pdk.LogDebug, "MBID lookup did not return a Spotify ID, trying metadata…")
|
||||||
|
} else {
|
||||||
|
pdk.Log(pdk.LogDebug, "No MBZRecordingID available, skipping MBID lookup")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try metadata lookup
|
||||||
|
if primary != "" && track.Title != "" {
|
||||||
|
if trackID := trySpotifyFromMetadata(primary, track.Title, track.Album); trackID != "" {
|
||||||
|
directURL := "https://open.spotify.com/track/" + trackID
|
||||||
|
_ = host.CacheSetString(cacheKey, directURL, spotifyCacheTTLHit)
|
||||||
|
pdk.Log(pdk.LogInfo, fmt.Sprintf("Resolved Spotify via metadata for %q - %q: %s", primary, track.Title, directURL))
|
||||||
|
return directURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fallback to search URL
|
||||||
|
searchURL := spotifySearchURL(track.Artist, track.Title)
|
||||||
|
_ = host.CacheSetString(cacheKey, searchURL, spotifyCacheTTLMiss)
|
||||||
|
pdk.Log(pdk.LogInfo, fmt.Sprintf("Spotify resolution missed, falling back to search URL for %q - %q: %s", primary, track.Title, searchURL))
|
||||||
|
return searchURL
|
||||||
|
}
|
||||||
219
spotify_test.go
Normal file
219
spotify_test.go
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||||
|
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||||
|
"github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("Spotify", func() {
|
||||||
|
Describe("spotifySearchURL", func() {
|
||||||
|
DescribeTable("constructs Spotify search URL",
|
||||||
|
func(expectedURL string, terms ...string) {
|
||||||
|
Expect(spotifySearchURL(terms...)).To(Equal(expectedURL))
|
||||||
|
},
|
||||||
|
Entry("artist and title", "https://open.spotify.com/search/Rick%20Astley%20Never%20Gonna%20Give%20You%20Up", "Rick Astley", "Never Gonna Give You Up"),
|
||||||
|
Entry("single term", "https://open.spotify.com/search/Radiohead", "Radiohead"),
|
||||||
|
Entry("empty terms", "", "", ""),
|
||||||
|
Entry("one empty term", "https://open.spotify.com/search/Solo%20Artist", "Solo Artist", ""),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("spotifyCacheKey", func() {
|
||||||
|
It("produces identical keys for identical inputs", func() {
|
||||||
|
key1 := spotifyCacheKey("Radiohead", "Karma Police", "OK Computer")
|
||||||
|
key2 := spotifyCacheKey("Radiohead", "Karma Police", "OK Computer")
|
||||||
|
Expect(key1).To(Equal(key2))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("produces different keys for different albums", func() {
|
||||||
|
key1 := spotifyCacheKey("Radiohead", "Karma Police", "OK Computer")
|
||||||
|
key2 := spotifyCacheKey("Radiohead", "Karma Police", "The Bends")
|
||||||
|
Expect(key1).ToNot(Equal(key2))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("uses the correct prefix", func() {
|
||||||
|
key := spotifyCacheKey("Radiohead", "Karma Police", "OK Computer")
|
||||||
|
Expect(key).To(HavePrefix("spotify.url."))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("is case-insensitive", func() {
|
||||||
|
keyUpper := spotifyCacheKey("Radiohead", "Karma Police", "OK Computer")
|
||||||
|
keyLower := spotifyCacheKey("radiohead", "karma police", "ok computer")
|
||||||
|
Expect(keyUpper).To(Equal(keyLower))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("parseSpotifyID", func() {
|
||||||
|
DescribeTable("extracts first Spotify track ID from ListenBrainz response",
|
||||||
|
func(body, expectedID string) {
|
||||||
|
Expect(parseSpotifyID([]byte(body))).To(Equal(expectedID))
|
||||||
|
},
|
||||||
|
Entry("valid single result",
|
||||||
|
`[{"spotify_track_ids":["4tIGK5G9hNDA50ZdGioZRG"]}]`, "4tIGK5G9hNDA50ZdGioZRG"),
|
||||||
|
Entry("multiple IDs picks first",
|
||||||
|
`[{"artist_name":"Lil Baby & Drake","track_name":"Yes Indeed","spotify_track_ids":["6vN77lE9LK6HP2DewaN6HZ","4wlLbLeDWbA6TzwZFp1UaK"]}]`, "6vN77lE9LK6HP2DewaN6HZ"),
|
||||||
|
Entry("valid result with extra fields",
|
||||||
|
`[{"artist_name":"Radiohead","track_name":"Karma Police","spotify_track_ids":["63OQupATfueTdZMWIV7nzz"],"release_name":"OK Computer"}]`, "63OQupATfueTdZMWIV7nzz"),
|
||||||
|
Entry("empty spotify_track_ids array",
|
||||||
|
`[{"spotify_track_ids":[]}]`, ""),
|
||||||
|
Entry("no spotify_track_ids field",
|
||||||
|
`[{"artist_name":"Unknown"}]`, ""),
|
||||||
|
Entry("empty array",
|
||||||
|
`[]`, ""),
|
||||||
|
Entry("invalid JSON",
|
||||||
|
`not json`, ""),
|
||||||
|
Entry("null first result falls through to second",
|
||||||
|
`[{"spotify_track_ids":[]},{"spotify_track_ids":["6vN77lE9LK6HP2DewaN6HZ"]}]`, "6vN77lE9LK6HP2DewaN6HZ"),
|
||||||
|
Entry("skips invalid ID with special characters",
|
||||||
|
`[{"spotify_track_ids":["abc!@#$%^&*()_+=-12345"]}]`, ""),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("isValidSpotifyID", func() {
|
||||||
|
DescribeTable("validates Spotify track IDs",
|
||||||
|
func(id string, expected bool) {
|
||||||
|
Expect(isValidSpotifyID(id)).To(Equal(expected))
|
||||||
|
},
|
||||||
|
Entry("valid 22-char ID", "6vN77lE9LK6HP2DewaN6HZ", true),
|
||||||
|
Entry("another valid ID", "4tIGK5G9hNDA50ZdGioZRG", true),
|
||||||
|
Entry("short valid ID", "abc123", true),
|
||||||
|
Entry("special characters", "6vN77lE9!K6HP2DewaN6HZ", false),
|
||||||
|
Entry("spaces", "6vN77 E9LK6HP2DewaN6HZ", false),
|
||||||
|
Entry("empty string", "", false),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("ListenBrainz request payloads", func() {
|
||||||
|
It("builds valid JSON for MBID requests", func() {
|
||||||
|
mbid := "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
||||||
|
body := []byte(fmt.Sprintf(`[{"recording_mbid":%q}]`, mbid))
|
||||||
|
var parsed []map[string]string
|
||||||
|
Expect(json.Unmarshal(body, &parsed)).To(Succeed())
|
||||||
|
Expect(parsed[0]["recording_mbid"]).To(Equal(mbid))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("builds valid JSON for metadata requests with special characters", func() {
|
||||||
|
artist := `Guns N' Roses`
|
||||||
|
title := `Sweet Child O' Mine`
|
||||||
|
album := `Appetite for Destruction`
|
||||||
|
payload := fmt.Sprintf(`[{"artist_name":%q,"track_name":%q,"release_name":%q}]`, artist, title, album)
|
||||||
|
var parsed []map[string]string
|
||||||
|
Expect(json.Unmarshal([]byte(payload), &parsed)).To(Succeed())
|
||||||
|
Expect(parsed[0]["artist_name"]).To(Equal(artist))
|
||||||
|
Expect(parsed[0]["track_name"]).To(Equal(title))
|
||||||
|
Expect(parsed[0]["release_name"]).To(Equal(album))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("resolveSpotifyURL", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
pdk.ResetMock()
|
||||||
|
host.CacheMock.ExpectedCalls = nil
|
||||||
|
host.CacheMock.Calls = nil
|
||||||
|
host.HTTPMock.ExpectedCalls = nil
|
||||||
|
host.HTTPMock.Calls = nil
|
||||||
|
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns cached URL on cache hit", func() {
|
||||||
|
host.CacheMock.On("GetString", spotifyURLKey).Return("https://open.spotify.com/track/cached123", true, nil)
|
||||||
|
|
||||||
|
url := resolveSpotifyURL(scrobbler.TrackInfo{
|
||||||
|
Title: "Karma Police",
|
||||||
|
Artist: "Radiohead",
|
||||||
|
Artists: []scrobbler.ArtistRef{{Name: "Radiohead"}},
|
||||||
|
Album: "OK Computer",
|
||||||
|
})
|
||||||
|
Expect(url).To(Equal("https://open.spotify.com/track/cached123"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("resolves via MBID when available", func() {
|
||||||
|
host.CacheMock.On("GetString", spotifyURLKey).Return("", false, nil)
|
||||||
|
host.CacheMock.On("SetString", spotifyURLKey, mock.Anything, mock.Anything).Return(nil)
|
||||||
|
|
||||||
|
// Mock the MBID HTTP request
|
||||||
|
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||||
|
return req.URL == "https://labs.api.listenbrainz.org/spotify-id-from-mbid/json"
|
||||||
|
})).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[{"spotify_track_ids":["63OQupATfueTdZMWIV7nzz"]}]`)}, nil)
|
||||||
|
|
||||||
|
url := resolveSpotifyURL(scrobbler.TrackInfo{
|
||||||
|
Title: "Karma Police",
|
||||||
|
Artist: "Radiohead",
|
||||||
|
Artists: []scrobbler.ArtistRef{{Name: "Radiohead"}},
|
||||||
|
Album: "OK Computer",
|
||||||
|
MBZRecordingID: "mbid-123",
|
||||||
|
})
|
||||||
|
Expect(url).To(Equal("https://open.spotify.com/track/63OQupATfueTdZMWIV7nzz"))
|
||||||
|
host.CacheMock.AssertCalled(GinkgoT(), "SetString", spotifyURLKey, "https://open.spotify.com/track/63OQupATfueTdZMWIV7nzz", spotifyCacheTTLHit)
|
||||||
|
})
|
||||||
|
|
||||||
|
It("falls back to metadata lookup when MBID fails", func() {
|
||||||
|
host.CacheMock.On("GetString", spotifyURLKey).Return("", false, nil)
|
||||||
|
host.CacheMock.On("SetString", spotifyURLKey, mock.Anything, mock.Anything).Return(nil)
|
||||||
|
|
||||||
|
// MBID request fails
|
||||||
|
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||||
|
return req.URL == "https://labs.api.listenbrainz.org/spotify-id-from-mbid/json"
|
||||||
|
})).Return(&host.HTTPResponse{StatusCode: 404, Body: []byte(`[]`)}, nil)
|
||||||
|
|
||||||
|
// Metadata request succeeds
|
||||||
|
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||||
|
return req.URL == "https://labs.api.listenbrainz.org/spotify-id-from-metadata/json"
|
||||||
|
})).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[{"spotify_track_ids":["4wlLbLeDWbA6TzwZFp1UaK"]}]`)}, nil)
|
||||||
|
|
||||||
|
url := resolveSpotifyURL(scrobbler.TrackInfo{
|
||||||
|
Title: "Karma Police",
|
||||||
|
Artist: "Radiohead",
|
||||||
|
Artists: []scrobbler.ArtistRef{{Name: "Radiohead"}},
|
||||||
|
Album: "OK Computer",
|
||||||
|
MBZRecordingID: "mbid-123",
|
||||||
|
})
|
||||||
|
Expect(url).To(Equal("https://open.spotify.com/track/4wlLbLeDWbA6TzwZFp1UaK"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("falls back to search URL when both lookups fail", func() {
|
||||||
|
host.CacheMock.On("GetString", spotifyURLKey).Return("", false, nil)
|
||||||
|
host.CacheMock.On("SetString", spotifyURLKey, mock.Anything, mock.Anything).Return(nil)
|
||||||
|
|
||||||
|
// No MBID, metadata request fails
|
||||||
|
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||||
|
return req.URL == "https://labs.api.listenbrainz.org/spotify-id-from-metadata/json"
|
||||||
|
})).Return(&host.HTTPResponse{StatusCode: 500, Body: []byte(`error`)}, nil)
|
||||||
|
|
||||||
|
url := resolveSpotifyURL(scrobbler.TrackInfo{
|
||||||
|
Title: "Karma Police",
|
||||||
|
Artist: "Radiohead",
|
||||||
|
Artists: []scrobbler.ArtistRef{{Name: "Radiohead"}},
|
||||||
|
Album: "OK Computer",
|
||||||
|
})
|
||||||
|
Expect(url).To(HavePrefix("https://open.spotify.com/search/"))
|
||||||
|
Expect(url).To(ContainSubstring("Radiohead"))
|
||||||
|
host.CacheMock.AssertCalled(GinkgoT(), "SetString", spotifyURLKey, mock.Anything, spotifyCacheTTLMiss)
|
||||||
|
})
|
||||||
|
|
||||||
|
It("uses Artists[0] for primary artist", func() {
|
||||||
|
host.CacheMock.On("GetString", spotifyURLKey).Return("", false, nil)
|
||||||
|
host.CacheMock.On("SetString", spotifyURLKey, mock.Anything, mock.Anything).Return(nil)
|
||||||
|
|
||||||
|
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||||
|
return req.URL == "https://labs.api.listenbrainz.org/spotify-id-from-metadata/json"
|
||||||
|
})).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[{"spotify_track_ids":["4tIGK5G9hNDA50ZdGioZRG"]}]`)}, nil)
|
||||||
|
|
||||||
|
url := resolveSpotifyURL(scrobbler.TrackInfo{
|
||||||
|
Title: "Some Song",
|
||||||
|
Artist: "",
|
||||||
|
Album: "Some Album",
|
||||||
|
Artists: []scrobbler.ArtistRef{{Name: "Fallback Artist"}},
|
||||||
|
})
|
||||||
|
Expect(url).To(Equal("https://open.spotify.com/track/4tIGK5G9hNDA50ZdGioZRG"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user