Compare commits
17 Commits
v0.3.0
...
v1.0.0-beta-1
| Author | SHA1 | Date | |
|---|---|---|---|
| 47b444d72a | |||
| 323bf7089a | |||
| 24615fda7b | |||
| 5f57906aca | |||
| 606a7f2389 | |||
| 9d9dce052a | |||
| c7c484c714 | |||
| 4600172dbf | |||
| 3ce0277ecb | |||
| 6d7448f3ee | |||
| c22b950be3 | |||
| f96884e3e5 | |||
| 1552322429 | |||
| 8d07bc6120 | |||
| 1a236fd00f | |||
| 223ebf0539 | |||
| 05714ace50 |
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
@@ -30,6 +30,20 @@ jobs:
|
||||
- name: Run tests
|
||||
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
|
||||
run: make package
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@ on:
|
||||
description: "Release version (e.g., 1.2.3, without the 'v' prefix)"
|
||||
required: true
|
||||
type: string
|
||||
beta:
|
||||
description: "Beta number (1, 2, 3...). Leave empty for stable release"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -20,17 +25,24 @@ jobs:
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
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)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Compute full version
|
||||
run: |
|
||||
VERSION="${{ inputs.version }}"
|
||||
BETA="${{ inputs.beta }}"
|
||||
if [[ -n "$BETA" && "$BETA" != "0" ]]; then
|
||||
VERSION="${VERSION}-beta-${BETA}"
|
||||
fi
|
||||
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Check tag does not already exist
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
if git ls-remote --tags origin "refs/tags/v${VERSION}" | grep -q .; then
|
||||
echo "::error::Tag v${VERSION} already exists"
|
||||
@@ -46,14 +58,10 @@ jobs:
|
||||
run: go test -race ./...
|
||||
|
||||
- name: Update manifest.json version
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
jq --arg v "$VERSION" '.version = $v' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
|
||||
|
||||
- name: Commit, tag, and push
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
@@ -74,7 +82,8 @@ jobs:
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ inputs.version }}
|
||||
tag_name: v${{ env.VERSION }}
|
||||
draft: true
|
||||
prerelease: ${{ inputs.beta != '' && inputs.beta != '0' }}
|
||||
files: discord-rich-presence.ndp
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
*.wasm
|
||||
*.ndp
|
||||
tmp
|
||||
discord-rich-presence
|
||||
@@ -3,12 +3,18 @@ SHELL := /usr/bin/env bash
|
||||
|
||||
PLUGIN_NAME := discord-rich-presence
|
||||
WASM_FILE := plugin.wasm
|
||||
TINYGO := $(shell command -v tinygo 2> /dev/null)
|
||||
|
||||
test:
|
||||
go test -race ./...
|
||||
|
||||
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
|
||||
zip $(PLUGIN_NAME).ndp $(WASM_FILE) manifest.json
|
||||
@@ -17,7 +23,7 @@ clean:
|
||||
rm -f $(WASM_FILE) $(PLUGIN_NAME).ndp
|
||||
|
||||
release:
|
||||
@if [[ ! "${V}" =~ ^[0-9]+\.[0-9]+\.[0-9]+.*$$ ]]; then echo "Usage: make release V=X.X.X"; exit 1; fi
|
||||
gh workflow run create-release.yml -f version=${V}
|
||||
@echo "Release v${V} workflow triggered. Check progress: gh run list --workflow=create-release.yml"
|
||||
@if [[ ! "$${V}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$$ ]]; then echo "Usage: make release V=X.X.X [BETA=N]"; exit 1; fi
|
||||
gh workflow run create-release.yml -f version=$${V} -f beta=$(BETA)
|
||||
@echo "Release v$${V}$$(if [ -n "$(BETA)" ] && [ "$(BETA)" != "0" ]; then echo -beta-$(BETA); fi) workflow triggered. Check progress: gh run list --workflow=create-release.yml"
|
||||
.PHONY: release
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
[](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)
|
||||
|
||||
**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.
|
||||
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.
|
||||
@@ -14,13 +16,17 @@ Based on the [Navicord](https://github.com/logixism/navicord) project.
|
||||
## Features
|
||||
|
||||
- Shows currently playing track with title, artist, and album art
|
||||
- Clickable track title and artist name link to Spotify (direct track link via [ListenBrainz](https://listenbrainz.org), falls back to Spotify search)
|
||||
- Clickable album art links to the Spotify track page
|
||||
- Navidrome logo overlay on album art when track artwork is available
|
||||
- Customizable activity name: "Navidrome" is default, but can be configured to display track title, artist, or album
|
||||
- Displays playback progress with start/end timestamps
|
||||
- Automatic presence clearing when track finishes
|
||||
- Multi-user support with individual Discord tokens
|
||||
- Optional album art from [Cover Art Archive](https://coverartarchive.org) for MusicBrainz-tagged music
|
||||
- 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/ss-richpresence.webp">
|
||||
|
||||
|
||||
## Installation
|
||||
@@ -43,7 +49,11 @@ 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**
|
||||
2. Fill in the configuration:
|
||||
- **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
|
||||
- **Use artwork from Cover Art Archive**: Enable this if your music has MusicBrainz tags (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
|
||||
|
||||
### Step 5: Enable Discord Activity Sharing
|
||||
@@ -75,7 +85,18 @@ For album artwork to display in Discord, Discord needs to be able to access the
|
||||
2. **Restart Navidrome** (required for ND_BASEURL changes)
|
||||
3. In plugin settings: **Disable** "Upload to uguu.se"
|
||||
|
||||
### Option 2: Private Instance with uguu.se Upload
|
||||
### Option 2: Cover Art Archive (for MusicBrainz-tagged music)
|
||||
**Use this if**: Your music is tagged with MusicBrainz IDs
|
||||
|
||||
**Setup**:
|
||||
1. In plugin settings: **Enable** "Use artwork from Cover Art Archive"
|
||||
2. No other configuration needed
|
||||
|
||||
**How it works**: The plugin checks the [Cover Art Archive](https://coverartarchive.org) for album artwork using the track's MusicBrainz Release ID. If the specific release has no art, it falls back to the Release Group (which finds art from any edition of the same album). The resolved image URL is passed directly to Discord — no upload needed. Results are cached for 24 hours.
|
||||
|
||||
**Note**: This option takes priority over uguu.se and direct Navidrome URLs when enabled. It only works for tracks that have MusicBrainz IDs in their metadata — tracks without IDs will fall through to the next method.
|
||||
|
||||
### Option 3: Private Instance with uguu.se Upload
|
||||
**Use this if**: Your Navidrome is only accessible locally (home network, behind VPN, etc.)
|
||||
|
||||
**Setup**:
|
||||
@@ -87,8 +108,53 @@ For album artwork to display in Discord, Discord needs to be able to access the
|
||||
### Troubleshooting Album Art
|
||||
- **No album art showing**: Check Navidrome logs for errors
|
||||
- **Using public instance**: Verify ND_BASEURL is correct and Navidrome was restarted
|
||||
- **Using Cover Art Archive**: Verify your music has MusicBrainz IDs (check file tags for `MUSICBRAINZ_ALBUMID`)
|
||||
- **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**
|
||||
|
||||
<img alt="Plugin configuration panel showing all available settings" src="https://raw.githubusercontent.com/navidrome/discord-rich-presence-plugin/master/.github/ss-config.webp">
|
||||
|
||||
### 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
|
||||
|
||||
#### Use artwork from Cover Art Archive
|
||||
- **When to enable**: Your music is tagged with MusicBrainz IDs and you want album art from the Cover Art Archive
|
||||
- **What it does**: Checks the [Cover Art Archive](https://coverartarchive.org) for artwork using MusicBrainz Release ID, with a fallback to Release Group ID. Takes priority over other artwork methods when enabled.
|
||||
- **When to disable**: Your music isn't tagged with MusicBrainz IDs
|
||||
|
||||
#### 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
|
||||
|
||||
### Plugin Capabilities
|
||||
@@ -104,10 +170,10 @@ The plugin implements three Navidrome capabilities:
|
||||
### Host Services
|
||||
|
||||
| Service | Usage |
|
||||
|-----------------|---------------------------------------------------------------------|
|
||||
| **HTTP** | Discord API calls (gateway discovery, external assets registration) |
|
||||
|-----------------|------------------------------------------------------------------------------------------------------|
|
||||
| **HTTP** | Discord API calls (gateway discovery, external assets registration), Cover Art Archive lookups, ListenBrainz Spotify resolution |
|
||||
| **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 |
|
||||
| **Artwork** | Track artwork public URL resolution |
|
||||
| **SubsonicAPI** | Fetches track artwork data for image hosting upload |
|
||||
@@ -132,77 +198,39 @@ Navidrome plugins are stateless - each call creates a fresh instance. This plugi
|
||||
|
||||
### Image Processing
|
||||
|
||||
Discord requires images to be registered via their external assets API. The plugin:
|
||||
1. Fetches track artwork URL from Navidrome
|
||||
2. Registers it with Discord's API to get an `mp:` prefixed URL
|
||||
3. Caches the result (4 hours for track art, 48 hours for default image)
|
||||
4. Falls back to a default image if artwork is unavailable
|
||||
Discord requires images to be registered via their external assets API. The plugin resolves artwork URLs using a priority chain:
|
||||
|
||||
**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.
|
||||
1. **Cover Art Archive** (if enabled): HEAD request to check for artwork by MusicBrainz Release ID, with fallback to Release Group ID. The resolved `archive.org` URL is used directly.
|
||||
2. **uguu.se** (if enabled): Fetches artwork from Navidrome and uploads to temporary hosting.
|
||||
3. **Direct URL**: Uses the Navidrome artwork URL directly (requires public instance).
|
||||
|
||||
The resolved URL is then registered with Discord's external assets API to get an `mp:` prefixed URL, which is cached (4 hours for track art, 48 hours for default image). Falls back to a default image if artwork is unavailable.
|
||||
|
||||
### Spotify Linking
|
||||
|
||||
The plugin enriches the Discord presence with clickable Spotify links so others can easily find what you're listening to:
|
||||
|
||||
- **Track title** → links to the Spotify track (or a Spotify search as fallback)
|
||||
- **Artist name** → links to a Spotify search for the artist
|
||||
- **Album art** → links to the Spotify track page
|
||||
|
||||
Track URLs are resolved via the [ListenBrainz Labs API](https://labs.api.listenbrainz.org):
|
||||
1. If the track has a MusicBrainz Recording ID (MBID), that is used for an exact lookup
|
||||
2. Otherwise, artist name, track title, and album are used for a metadata-based lookup
|
||||
3. If neither resolves, a Spotify search URL is used as a fallback
|
||||
|
||||
Resolved URLs are cached (30 days for direct track links, 4 hours for search fallbacks).
|
||||
|
||||
### Files
|
||||
|
||||
| File | Description |
|
||||
|--------------------------------|------------------------------------------------------------------------|
|
||||
| [main.go](main.go) | Plugin entry point, scrobbler and scheduler implementations |
|
||||
|----------------------------------|-------------------------------------------------------------------------------------|
|
||||
| [main.go](main.go) | Plugin entry point, scrobbler and scheduler implementations, Spotify URL resolution |
|
||||
| [rpc.go](rpc.go) | Discord gateway communication, WebSocket handling, activity management |
|
||||
| [coverart.go](coverart.go) | Artwork URL handling and optional uguu.se image hosting |
|
||||
| [coverart.go](coverart.go) | Artwork URL handling, Cover Art Archive lookups, and optional uguu.se image hosting |
|
||||
| [manifest.json](manifest.json) | Plugin metadata and permission declarations |
|
||||
| [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
|
||||
|
||||
### Prerequisites
|
||||
|
||||
+112
-15
@@ -7,10 +7,94 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
|
||||
)
|
||||
|
||||
// Configuration key for uguu.se image hosting
|
||||
const uguuEnabledKey = "uguuenabled"
|
||||
// Cache TTLs for cover art lookups
|
||||
const (
|
||||
caaCacheTTLHit int64 = 24 * 60 * 60 // 24 hours for resolved CAA artwork
|
||||
caaCacheTTLMiss int64 = 4 * 60 * 60 // 4 hours for CAA misses
|
||||
uguuCacheTTL int64 = 150 * 60 // 2.5 hours for uguu.se uploads
|
||||
|
||||
caaTimeOut = 4000 // 4 seconds timeout for CAA HEAD requests to avoid blocking NowPlaying
|
||||
)
|
||||
|
||||
// headCoverArt sends a HEAD request to the given CAA URL without following redirects.
|
||||
// Returns (location, true) on 307 with a Location header (image exists),
|
||||
// ("", true) on 404 (definitive miss — safe to cache),
|
||||
// ("", false) on network errors or unexpected responses (transient — do not cache).
|
||||
func headCoverArt(url string) (string, bool) {
|
||||
resp, err := host.HTTPSend(host.HTTPRequest{
|
||||
Method: "HEAD",
|
||||
URL: url,
|
||||
NoFollowRedirects: true,
|
||||
TimeoutMs: caaTimeOut,
|
||||
})
|
||||
if err != nil {
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("CAA HEAD request failed for %s: %v", url, err))
|
||||
return "", false
|
||||
}
|
||||
if resp.StatusCode == 404 {
|
||||
return "", true
|
||||
}
|
||||
if resp.StatusCode != 307 {
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("CAA HEAD unexpected status %d for %s", resp.StatusCode, url))
|
||||
return "", false
|
||||
}
|
||||
location := resp.Headers["Location"]
|
||||
if location == "" {
|
||||
pdk.Log(pdk.LogWarn, fmt.Sprintf("CAA returned 307 but no Location header for %s", url))
|
||||
}
|
||||
return location, true
|
||||
}
|
||||
|
||||
// getImageViaCoverArt checks the Cover Art Archive for album artwork.
|
||||
// Tries the release first, then falls back to the release group.
|
||||
// Returns the archive.org image URL on success, "" on failure.
|
||||
func getImageViaCoverArt(mbzAlbumID, mbzReleaseGroupID string) string {
|
||||
if mbzAlbumID == "" && mbzReleaseGroupID == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Determine cache key: use album ID when available, otherwise release group ID
|
||||
cacheKey := "caa.artwork." + mbzAlbumID
|
||||
if mbzAlbumID == "" {
|
||||
cacheKey = "caa.artwork.rg." + mbzReleaseGroupID
|
||||
}
|
||||
|
||||
// Check cache
|
||||
cachedURL, exists, err := host.CacheGetString(cacheKey)
|
||||
if err == nil && exists {
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("CAA cache hit for %s", cacheKey))
|
||||
return cachedURL
|
||||
}
|
||||
|
||||
// Try release first
|
||||
var imageURL string
|
||||
definitive := false
|
||||
if mbzAlbumID != "" {
|
||||
imageURL, definitive = headCoverArt(fmt.Sprintf("https://coverartarchive.org/release/%s/front-500", mbzAlbumID))
|
||||
}
|
||||
|
||||
// Fall back to release group
|
||||
if imageURL == "" && mbzReleaseGroupID != "" {
|
||||
imageURL, definitive = headCoverArt(fmt.Sprintf("https://coverartarchive.org/release-group/%s/front-500", mbzReleaseGroupID))
|
||||
}
|
||||
|
||||
// Cache hits always; only cache misses if the response was definitive (404),
|
||||
// not transient failures (network errors, 5xx) which should be retried sooner.
|
||||
if imageURL != "" {
|
||||
_ = host.CacheSetString(cacheKey, imageURL, caaCacheTTLHit)
|
||||
} else if definitive {
|
||||
_ = host.CacheSetString(cacheKey, "", caaCacheTTLMiss)
|
||||
}
|
||||
|
||||
if imageURL != "" {
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("CAA resolved artwork for %s: %s", cacheKey, imageURL))
|
||||
}
|
||||
|
||||
return imageURL
|
||||
}
|
||||
|
||||
// uguu.se API response
|
||||
type uguuResponse struct {
|
||||
@@ -20,13 +104,22 @@ type uguuResponse struct {
|
||||
} `json:"files"`
|
||||
}
|
||||
|
||||
// getImageURL retrieves the track artwork URL, optionally uploading to uguu.se.
|
||||
func getImageURL(username, trackID string) string {
|
||||
// getImageURL retrieves the track artwork URL, checking CAA first if enabled,
|
||||
// then uguu.se, then direct Navidrome URL.
|
||||
func getImageURL(username string, track scrobbler.TrackInfo) string {
|
||||
caaEnabled, _ := pdk.GetConfig(caaEnabledKey)
|
||||
if caaEnabled == "true" {
|
||||
if url := getImageViaCoverArt(track.MBZAlbumID, track.MBZReleaseGroupID); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
uguuEnabled, _ := pdk.GetConfig(uguuEnabledKey)
|
||||
if uguuEnabled == "true" {
|
||||
return getImageViaUguu(username, trackID)
|
||||
return getImageViaUguu(username, track.ID)
|
||||
}
|
||||
return getImageDirect(trackID)
|
||||
|
||||
return getImageDirect(track.ID)
|
||||
}
|
||||
|
||||
// getImageDirect returns the artwork URL directly from Navidrome (current behavior).
|
||||
@@ -68,7 +161,7 @@ func getImageViaUguu(username, trackID string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
_ = host.CacheSetString(cacheKey, url, 9000)
|
||||
_ = host.CacheSetString(cacheKey, url, uguuCacheTTL)
|
||||
return url
|
||||
}
|
||||
|
||||
@@ -84,17 +177,21 @@ func uploadToUguu(imageData []byte, contentType string) (string, error) {
|
||||
body = append(body, imageData...)
|
||||
body = append(body, []byte(fmt.Sprintf("\r\n--%s--\r\n", boundary))...)
|
||||
|
||||
req := pdk.NewHTTPRequest(pdk.MethodPost, "https://uguu.se/upload")
|
||||
req.SetHeader("Content-Type", fmt.Sprintf("multipart/form-data; boundary=%s", boundary))
|
||||
req.SetBody(body)
|
||||
|
||||
resp := req.Send()
|
||||
if resp.Status() >= 400 {
|
||||
return "", fmt.Errorf("uguu.se upload failed: HTTP %d", resp.Status())
|
||||
resp, err := host.HTTPSend(host.HTTPRequest{
|
||||
Method: "POST",
|
||||
URL: "https://uguu.se/upload",
|
||||
Headers: map[string]string{"Content-Type": fmt.Sprintf("multipart/form-data; boundary=%s", boundary)},
|
||||
Body: body,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("uguu.se upload failed: %w", err)
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("uguu.se upload failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result uguuResponse
|
||||
if err := json.Unmarshal(resp.Body(), &result); err != nil {
|
||||
if err := json.Unmarshal(resp.Body, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse uguu.se response: %w", err)
|
||||
}
|
||||
|
||||
|
||||
+254
-16
@@ -5,12 +5,70 @@ import (
|
||||
|
||||
"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("headCoverArt", func() {
|
||||
BeforeEach(func() {
|
||||
pdk.ResetMock()
|
||||
host.HTTPMock.ExpectedCalls = nil
|
||||
host.HTTPMock.Calls = nil
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
})
|
||||
|
||||
It("returns Location header and definitive=true on 307 response", func() {
|
||||
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||
return req.Method == "HEAD" &&
|
||||
req.URL == "https://coverartarchive.org/release/test-mbid/front-500" &&
|
||||
req.NoFollowRedirects == true
|
||||
})).Return(&host.HTTPResponse{
|
||||
StatusCode: 307,
|
||||
Headers: map[string]string{"Location": "https://archive.org/download/mbid-test/thumb500.jpg"},
|
||||
}, nil)
|
||||
|
||||
result, definitive := headCoverArt("https://coverartarchive.org/release/test-mbid/front-500")
|
||||
Expect(result).To(Equal("https://archive.org/download/mbid-test/thumb500.jpg"))
|
||||
Expect(definitive).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns empty and definitive=true on 404 response", func() {
|
||||
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||
return req.Method == "HEAD" && req.NoFollowRedirects == true
|
||||
})).Return(&host.HTTPResponse{StatusCode: 404}, nil)
|
||||
|
||||
result, definitive := headCoverArt("https://coverartarchive.org/release/no-art/front-500")
|
||||
Expect(result).To(BeEmpty())
|
||||
Expect(definitive).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns empty and definitive=false on HTTP error", func() {
|
||||
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||
return req.Method == "HEAD"
|
||||
})).Return((*host.HTTPResponse)(nil), errors.New("connection refused"))
|
||||
|
||||
result, definitive := headCoverArt("https://coverartarchive.org/release/err/front-500")
|
||||
Expect(result).To(BeEmpty())
|
||||
Expect(definitive).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns empty and definitive=true when Location header is missing on 307", func() {
|
||||
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||
return req.Method == "HEAD"
|
||||
})).Return(&host.HTTPResponse{
|
||||
StatusCode: 307,
|
||||
Headers: map[string]string{},
|
||||
}, nil)
|
||||
|
||||
result, definitive := headCoverArt("https://coverartarchive.org/release/no-location/front-500")
|
||||
Expect(result).To(BeEmpty())
|
||||
Expect(definitive).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("getImageURL", func() {
|
||||
BeforeEach(func() {
|
||||
pdk.ResetMock()
|
||||
@@ -20,45 +78,49 @@ var _ = Describe("getImageURL", func() {
|
||||
host.ArtworkMock.Calls = nil
|
||||
host.SubsonicAPIMock.ExpectedCalls = nil
|
||||
host.SubsonicAPIMock.Calls = nil
|
||||
host.HTTPMock.ExpectedCalls = nil
|
||||
host.HTTPMock.Calls = nil
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
})
|
||||
|
||||
Describe("uguu disabled (default)", func() {
|
||||
BeforeEach(func() {
|
||||
pdk.PDKMock.On("GetConfig", caaEnabledKey).Return("", false)
|
||||
pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false)
|
||||
})
|
||||
|
||||
It("returns artwork URL directly", func() {
|
||||
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil)
|
||||
|
||||
url := getImageURL("testuser", "track1")
|
||||
url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1"})
|
||||
Expect(url).To(Equal("https://example.com/art.jpg"))
|
||||
})
|
||||
|
||||
It("returns empty for localhost URL", func() {
|
||||
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("http://localhost:4533/art.jpg", nil)
|
||||
|
||||
url := getImageURL("testuser", "track1")
|
||||
url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1"})
|
||||
Expect(url).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns empty when artwork fetch fails", func() {
|
||||
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("", errors.New("not found"))
|
||||
|
||||
url := getImageURL("testuser", "track1")
|
||||
url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1"})
|
||||
Expect(url).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("uguu enabled", func() {
|
||||
BeforeEach(func() {
|
||||
pdk.PDKMock.On("GetConfig", caaEnabledKey).Return("", false)
|
||||
pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("true", true)
|
||||
})
|
||||
|
||||
It("returns cached URL when available", func() {
|
||||
host.CacheMock.On("GetString", "uguu.artwork.track1").Return("https://a.uguu.se/cached.jpg", true, nil)
|
||||
|
||||
url := getImageURL("testuser", "track1")
|
||||
url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1"})
|
||||
Expect(url).To(Equal("https://a.uguu.se/cached.jpg"))
|
||||
})
|
||||
|
||||
@@ -71,17 +133,16 @@ var _ = Describe("getImageURL", func() {
|
||||
Return("image/jpeg", imageData, nil)
|
||||
|
||||
// Mock uguu.se HTTP upload
|
||||
uguuReq := &pdk.HTTPRequest{}
|
||||
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, "https://uguu.se/upload").Return(uguuReq)
|
||||
pdk.PDKMock.On("Send", uguuReq).Return(pdk.NewStubHTTPResponse(200, nil,
|
||||
[]byte(`{"success":true,"files":[{"url":"https://a.uguu.se/uploaded.jpg"}]}`)))
|
||||
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||
return req.URL == "https://uguu.se/upload"
|
||||
})).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{"success":true,"files":[{"url":"https://a.uguu.se/uploaded.jpg"}]}`)}, nil)
|
||||
|
||||
// Mock cache set
|
||||
host.CacheMock.On("SetString", "uguu.artwork.track1", "https://a.uguu.se/uploaded.jpg", int64(9000)).Return(nil)
|
||||
host.CacheMock.On("SetString", "uguu.artwork.track1", "https://a.uguu.se/uploaded.jpg", uguuCacheTTL).Return(nil)
|
||||
|
||||
url := getImageURL("testuser", "track1")
|
||||
url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1"})
|
||||
Expect(url).To(Equal("https://a.uguu.se/uploaded.jpg"))
|
||||
host.CacheMock.AssertCalled(GinkgoT(), "SetString", "uguu.artwork.track1", "https://a.uguu.se/uploaded.jpg", int64(9000))
|
||||
host.CacheMock.AssertCalled(GinkgoT(), "SetString", "uguu.artwork.track1", "https://a.uguu.se/uploaded.jpg", uguuCacheTTL)
|
||||
})
|
||||
|
||||
It("returns empty when artwork data fetch fails", func() {
|
||||
@@ -89,7 +150,7 @@ var _ = Describe("getImageURL", func() {
|
||||
host.SubsonicAPIMock.On("CallRaw", "/getCoverArt?u=testuser&id=track1&size=300").
|
||||
Return("", []byte(nil), errors.New("fetch failed"))
|
||||
|
||||
url := getImageURL("testuser", "track1")
|
||||
url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1"})
|
||||
Expect(url).To(BeEmpty())
|
||||
})
|
||||
|
||||
@@ -98,12 +159,189 @@ var _ = Describe("getImageURL", func() {
|
||||
host.SubsonicAPIMock.On("CallRaw", "/getCoverArt?u=testuser&id=track1&size=300").
|
||||
Return("image/jpeg", []byte("fake-image-data"), nil)
|
||||
|
||||
uguuReq := &pdk.HTTPRequest{}
|
||||
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, "https://uguu.se/upload").Return(uguuReq)
|
||||
pdk.PDKMock.On("Send", uguuReq).Return(pdk.NewStubHTTPResponse(500, nil, []byte(`{"success":false}`)))
|
||||
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||
return req.URL == "https://uguu.se/upload"
|
||||
})).Return(&host.HTTPResponse{StatusCode: 500, Body: []byte(`{"success":false}`)}, nil)
|
||||
|
||||
url := getImageURL("testuser", "track1")
|
||||
url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1"})
|
||||
Expect(url).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("CAA enabled", func() {
|
||||
BeforeEach(func() {
|
||||
pdk.PDKMock.ExpectedCalls = nil
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
pdk.PDKMock.On("GetConfig", caaEnabledKey).Return("true", true)
|
||||
pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false)
|
||||
})
|
||||
|
||||
It("returns CAA URL when release HEAD succeeds", func() {
|
||||
host.CacheMock.On("GetString", "caa.artwork.album-id").Return("", false, nil)
|
||||
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||
return req.URL == "https://coverartarchive.org/release/album-id/front-500"
|
||||
})).Return(&host.HTTPResponse{
|
||||
StatusCode: 307,
|
||||
Headers: map[string]string{"Location": "https://archive.org/art.jpg"},
|
||||
}, nil)
|
||||
host.CacheMock.On("SetString", "caa.artwork.album-id", "https://archive.org/art.jpg", int64(86400)).Return(nil)
|
||||
|
||||
url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1", MBZAlbumID: "album-id", MBZReleaseGroupID: "rg-id"})
|
||||
Expect(url).To(Equal("https://archive.org/art.jpg"))
|
||||
host.ArtworkMock.AssertNotCalled(GinkgoT(), "GetTrackUrl", mock.Anything, mock.Anything)
|
||||
host.SubsonicAPIMock.AssertNotCalled(GinkgoT(), "CallRaw", mock.Anything)
|
||||
})
|
||||
|
||||
It("falls through to direct when CAA misses and uguu is disabled", func() {
|
||||
host.CacheMock.On("GetString", "caa.artwork.album-id").Return("", false, nil)
|
||||
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||
return req.URL == "https://coverartarchive.org/release/album-id/front-500"
|
||||
})).Return(&host.HTTPResponse{StatusCode: 404}, nil)
|
||||
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||
return req.URL == "https://coverartarchive.org/release-group/rg-id/front-500"
|
||||
})).Return(&host.HTTPResponse{StatusCode: 404}, nil)
|
||||
host.CacheMock.On("SetString", "caa.artwork.album-id", "", int64(14400)).Return(nil)
|
||||
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil)
|
||||
|
||||
url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1", MBZAlbumID: "album-id", MBZReleaseGroupID: "rg-id"})
|
||||
Expect(url).To(Equal("https://example.com/art.jpg"))
|
||||
})
|
||||
|
||||
It("falls through to uguu when CAA misses and uguu is enabled", func() {
|
||||
pdk.PDKMock.ExpectedCalls = nil
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
pdk.PDKMock.On("GetConfig", caaEnabledKey).Return("true", true)
|
||||
pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("true", true)
|
||||
|
||||
host.CacheMock.On("GetString", "caa.artwork.rg.rg-id").Return("", false, nil)
|
||||
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||
return req.URL == "https://coverartarchive.org/release-group/rg-id/front-500"
|
||||
})).Return(&host.HTTPResponse{StatusCode: 404}, nil)
|
||||
host.CacheMock.On("SetString", "caa.artwork.rg.rg-id", "", int64(14400)).Return(nil)
|
||||
|
||||
host.CacheMock.On("GetString", "uguu.artwork.track1").Return("https://a.uguu.se/cached.jpg", true, nil)
|
||||
|
||||
url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1", MBZReleaseGroupID: "rg-id"})
|
||||
Expect(url).To(Equal("https://a.uguu.se/cached.jpg"))
|
||||
})
|
||||
|
||||
It("skips CAA when no MBZ IDs are present", func() {
|
||||
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil)
|
||||
|
||||
url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1"})
|
||||
Expect(url).To(Equal("https://example.com/art.jpg"))
|
||||
host.HTTPMock.AssertNotCalled(GinkgoT(), "Send", mock.Anything)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("getImageViaCoverArt", 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", "caa.artwork.album-123").Return("https://archive.org/cached.jpg", true, nil)
|
||||
|
||||
result := getImageViaCoverArt("album-123", "rg-456")
|
||||
Expect(result).To(Equal("https://archive.org/cached.jpg"))
|
||||
host.HTTPMock.AssertNotCalled(GinkgoT(), "Send", mock.Anything)
|
||||
})
|
||||
|
||||
It("returns empty on cache hit with empty string (known miss)", func() {
|
||||
host.CacheMock.On("GetString", "caa.artwork.album-123").Return("", true, nil)
|
||||
|
||||
result := getImageViaCoverArt("album-123", "rg-456")
|
||||
Expect(result).To(BeEmpty())
|
||||
host.HTTPMock.AssertNotCalled(GinkgoT(), "Send", mock.Anything)
|
||||
})
|
||||
|
||||
It("returns release URL on 307 and caches it", func() {
|
||||
host.CacheMock.On("GetString", "caa.artwork.album-123").Return("", false, nil)
|
||||
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||
return req.URL == "https://coverartarchive.org/release/album-123/front-500"
|
||||
})).Return(&host.HTTPResponse{
|
||||
StatusCode: 307,
|
||||
Headers: map[string]string{"Location": "https://archive.org/release-art.jpg"},
|
||||
}, nil)
|
||||
host.CacheMock.On("SetString", "caa.artwork.album-123", "https://archive.org/release-art.jpg", int64(86400)).Return(nil)
|
||||
|
||||
result := getImageViaCoverArt("album-123", "rg-456")
|
||||
Expect(result).To(Equal("https://archive.org/release-art.jpg"))
|
||||
host.CacheMock.AssertCalled(GinkgoT(), "SetString", "caa.artwork.album-123", "https://archive.org/release-art.jpg", int64(86400))
|
||||
})
|
||||
|
||||
It("falls back to release-group when release returns 404", func() {
|
||||
host.CacheMock.On("GetString", "caa.artwork.album-123").Return("", false, nil)
|
||||
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||
return req.URL == "https://coverartarchive.org/release/album-123/front-500"
|
||||
})).Return(&host.HTTPResponse{StatusCode: 404}, nil)
|
||||
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||
return req.URL == "https://coverartarchive.org/release-group/rg-456/front-500"
|
||||
})).Return(&host.HTTPResponse{
|
||||
StatusCode: 307,
|
||||
Headers: map[string]string{"Location": "https://archive.org/rg-art.jpg"},
|
||||
}, nil)
|
||||
host.CacheMock.On("SetString", "caa.artwork.album-123", "https://archive.org/rg-art.jpg", int64(86400)).Return(nil)
|
||||
|
||||
result := getImageViaCoverArt("album-123", "rg-456")
|
||||
Expect(result).To(Equal("https://archive.org/rg-art.jpg"))
|
||||
})
|
||||
|
||||
It("caches empty string when both release and release-group fail", func() {
|
||||
host.CacheMock.On("GetString", "caa.artwork.album-123").Return("", false, nil)
|
||||
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||
return req.URL == "https://coverartarchive.org/release/album-123/front-500"
|
||||
})).Return(&host.HTTPResponse{StatusCode: 404}, nil)
|
||||
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||
return req.URL == "https://coverartarchive.org/release-group/rg-456/front-500"
|
||||
})).Return(&host.HTTPResponse{StatusCode: 404}, nil)
|
||||
host.CacheMock.On("SetString", "caa.artwork.album-123", "", int64(14400)).Return(nil)
|
||||
|
||||
result := getImageViaCoverArt("album-123", "rg-456")
|
||||
Expect(result).To(BeEmpty())
|
||||
host.CacheMock.AssertCalled(GinkgoT(), "SetString", "caa.artwork.album-123", "", int64(14400))
|
||||
})
|
||||
|
||||
It("does not cache miss on transient failure", func() {
|
||||
host.CacheMock.On("GetString", "caa.artwork.album-123").Return("", false, nil)
|
||||
// Both requests fail with network errors (transient)
|
||||
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||
return req.URL == "https://coverartarchive.org/release/album-123/front-500"
|
||||
})).Return((*host.HTTPResponse)(nil), errors.New("connection refused"))
|
||||
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||
return req.URL == "https://coverartarchive.org/release-group/rg-456/front-500"
|
||||
})).Return((*host.HTTPResponse)(nil), errors.New("timeout"))
|
||||
|
||||
result := getImageViaCoverArt("album-123", "rg-456")
|
||||
Expect(result).To(BeEmpty())
|
||||
// Should NOT cache the miss since failures were transient
|
||||
host.CacheMock.AssertNotCalled(GinkgoT(), "SetString", mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
It("tries only release-group when MBZAlbumID is empty", func() {
|
||||
host.CacheMock.On("GetString", "caa.artwork.rg.rg-456").Return("", false, nil)
|
||||
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||
return req.URL == "https://coverartarchive.org/release-group/rg-456/front-500"
|
||||
})).Return(&host.HTTPResponse{
|
||||
StatusCode: 307,
|
||||
Headers: map[string]string{"Location": "https://archive.org/rg-art.jpg"},
|
||||
}, nil)
|
||||
host.CacheMock.On("SetString", "caa.artwork.rg.rg-456", "https://archive.org/rg-art.jpg", int64(86400)).Return(nil)
|
||||
|
||||
result := getImageViaCoverArt("", "rg-456")
|
||||
Expect(result).To(Equal("https://archive.org/rg-art.jpg"))
|
||||
})
|
||||
|
||||
It("returns empty when both IDs are empty", func() {
|
||||
result := getImageViaCoverArt("", "")
|
||||
Expect(result).To(BeEmpty())
|
||||
host.HTTPMock.AssertNotCalled(GinkgoT(), "Send", mock.Anything)
|
||||
host.CacheMock.AssertNotCalled(GinkgoT(), "GetString", mock.Anything)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
module discord-rich-presence
|
||||
|
||||
go 1.25
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260207182358-29f98b889b11
|
||||
github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260320221607-03844a9a369a
|
||||
github.com/onsi/ginkgo/v2 v2.28.1
|
||||
github.com/onsi/gomega v1.39.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
@@ -15,19 +15,21 @@ require (
|
||||
github.com/extism/go-pdk v1.1.4-0.20260122165646-35abd9e2ba55 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef // indirect
|
||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
|
||||
github.com/maruel/natural v1.3.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/stretchr/objx v0.5.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -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-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef h1:xpF9fUHpoIrrjX24DURVKiwHcFpw19ndIs+FwTSMbno=
|
||||
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
|
||||
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/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
|
||||
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
|
||||
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
|
||||
github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
|
||||
github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260207182358-29f98b889b11 h1:VE4bqzkS6apWDtco9hAGdThFttjbYoLR0DEILAGDyyc=
|
||||
github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260207182358-29f98b889b11/go.mod h1:HijQ0Z0OeEa6LIwUJh6H9WqAptye096jHazmKXf+YV4=
|
||||
github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260320221607-03844a9a369a h1:EHllNfhSpL6F3EqM4M0GDHQZb7DyClw0y7afddd8XPg=
|
||||
github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260320221607-03844a9a369a/go.mod h1:5aedoevIXlwUFuR7kbd/WkjaiLg87D3XUFRGIwDBroo=
|
||||
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
|
||||
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
|
||||
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
|
||||
@@ -40,8 +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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
@@ -54,22 +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=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -28,6 +28,17 @@ const (
|
||||
clientIDKey = "clientid"
|
||||
usersKey = "users"
|
||||
activityNameKey = "activityname"
|
||||
spotifyLinksKey = "spotifylinks"
|
||||
caaEnabledKey = "caaenabled"
|
||||
uguuEnabledKey = "uguuenabled"
|
||||
)
|
||||
|
||||
const (
|
||||
navidromeWebsiteURL = "https://www.navidrome.org"
|
||||
|
||||
// navidromeLogoURL is the small overlay image shown in the bottom-right of the album art.
|
||||
// The file is stored in the plugins' GitHub repository so Discord can fetch it as an external asset.
|
||||
navidromeLogoURL = "https://raw.githubusercontent.com/navidrome/website/refs/heads/master/assets/icons/logo.webp"
|
||||
)
|
||||
|
||||
// Activity name display options
|
||||
@@ -147,14 +158,26 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error {
|
||||
|
||||
// Resolve the activity name based on configuration
|
||||
activityName := "Navidrome"
|
||||
statusDisplayType := statusDisplayDetails
|
||||
activityNameOption, _ := pdk.GetConfig(activityNameKey)
|
||||
switch activityNameOption {
|
||||
case activityNameTrack:
|
||||
activityName = input.Track.Title
|
||||
statusDisplayType = statusDisplayName
|
||||
case activityNameAlbum:
|
||||
activityName = input.Track.Album
|
||||
statusDisplayType = statusDisplayName
|
||||
case activityNameArtist:
|
||||
activityName = input.Track.Artist
|
||||
statusDisplayType = statusDisplayName
|
||||
}
|
||||
|
||||
// Resolve Spotify URLs if enabled
|
||||
var spotifyURL, artistSearchURL string
|
||||
spotifyLinksOption, _ := pdk.GetConfig(spotifyLinksKey)
|
||||
if spotifyLinksOption == "true" {
|
||||
spotifyURL = resolveSpotifyURL(input.Track)
|
||||
artistSearchURL = spotifySearchURL(input.Track.Artist)
|
||||
}
|
||||
|
||||
// Send activity update
|
||||
@@ -163,14 +186,21 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error {
|
||||
Name: activityName,
|
||||
Type: 2, // Listening
|
||||
Details: input.Track.Title,
|
||||
DetailsURL: spotifyURL,
|
||||
State: input.Track.Artist,
|
||||
StateURL: artistSearchURL,
|
||||
StatusDisplayType: statusDisplayType,
|
||||
Timestamps: activityTimestamps{
|
||||
Start: startTime,
|
||||
End: endTime,
|
||||
},
|
||||
Assets: activityAssets{
|
||||
LargeImage: getImageURL(input.Username, input.Track.ID),
|
||||
LargeImage: getImageURL(input.Username, input.Track),
|
||||
LargeText: input.Track.Album,
|
||||
LargeURL: spotifyURL,
|
||||
SmallImage: navidromeLogoURL,
|
||||
SmallText: "Navidrome",
|
||||
SmallURL: navidromeWebsiteURL,
|
||||
},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("%w: failed to send activity: %v", scrobbler.ScrobblerErrorRetryLater, err)
|
||||
|
||||
+28
-33
@@ -33,6 +33,8 @@ var _ = Describe("discordPlugin", func() {
|
||||
host.ArtworkMock.Calls = nil
|
||||
host.SubsonicAPIMock.ExpectedCalls = nil
|
||||
host.SubsonicAPIMock.Calls = nil
|
||||
host.HTTPMock.ExpectedCalls = nil
|
||||
host.HTTPMock.Calls = nil
|
||||
})
|
||||
|
||||
Describe("getConfig", func() {
|
||||
@@ -120,16 +122,18 @@ var _ = Describe("discordPlugin", func() {
|
||||
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", caaEnabledKey).Return("", false)
|
||||
pdk.PDKMock.On("GetConfig", activityNameKey).Return("", false)
|
||||
pdk.PDKMock.On("GetConfig", spotifyLinksKey).Return("", false)
|
||||
|
||||
// Connect mocks (isConnected check via heartbeat)
|
||||
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found"))
|
||||
|
||||
// Mock HTTP GET request for gateway discovery
|
||||
gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`)
|
||||
gatewayReq := &pdk.HTTPRequest{}
|
||||
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://discord.com/api/gateway").Return(gatewayReq).Once()
|
||||
pdk.PDKMock.On("Send", gatewayReq).Return(pdk.NewStubHTTPResponse(200, nil, gatewayResp)).Once()
|
||||
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||
return req.Method == "GET" && req.URL == "https://discord.com/api/gateway"
|
||||
})).Return(&host.HTTPResponse{StatusCode: 200, Body: gatewayResp}, nil)
|
||||
|
||||
// Mock WebSocket connection
|
||||
host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool {
|
||||
@@ -141,19 +145,13 @@ var _ = Describe("discordPlugin", func() {
|
||||
// Cancel existing clear schedule (may or may not exist)
|
||||
host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil)
|
||||
|
||||
// Image mocks - cache miss, will make HTTP request to Discord
|
||||
host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool {
|
||||
return strings.HasPrefix(key, "discord.image.")
|
||||
})).Return("", false, nil)
|
||||
host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
// Cache mocks (Discord image processing)
|
||||
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
|
||||
host.CacheMock.On("SetString", discordImageKey, mock.Anything, mock.Anything).Return(nil)
|
||||
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil)
|
||||
|
||||
// Mock HTTP request for Discord external assets API
|
||||
assetsReq := &pdk.HTTPRequest{}
|
||||
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.MatchedBy(func(url string) bool {
|
||||
return strings.Contains(url, "external-assets")
|
||||
})).Return(assetsReq)
|
||||
pdk.PDKMock.On("Send", assetsReq).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{"key":"test-key"}`)))
|
||||
// Mock HTTP POST requests (Discord external assets API)
|
||||
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{}`)}, nil)
|
||||
|
||||
// Schedule clear activity callback
|
||||
host.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil)
|
||||
@@ -173,18 +171,20 @@ var _ = Describe("discordPlugin", func() {
|
||||
})
|
||||
|
||||
DescribeTable("activity name configuration",
|
||||
func(configValue string, configExists bool, expectedName string) {
|
||||
func(configValue string, configExists bool, expectedName string, expectedDisplayType int) {
|
||||
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true)
|
||||
pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"testuser","token":"test-token"}]`, true)
|
||||
pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false)
|
||||
pdk.PDKMock.On("GetConfig", caaEnabledKey).Return("", false)
|
||||
pdk.PDKMock.On("GetConfig", activityNameKey).Return(configValue, configExists)
|
||||
pdk.PDKMock.On("GetConfig", spotifyLinksKey).Return("", false)
|
||||
|
||||
// Connect mocks
|
||||
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found"))
|
||||
gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`)
|
||||
gatewayReq := &pdk.HTTPRequest{}
|
||||
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://discord.com/api/gateway").Return(gatewayReq).Once()
|
||||
pdk.PDKMock.On("Send", gatewayReq).Return(pdk.NewStubHTTPResponse(200, nil, gatewayResp)).Once()
|
||||
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||
return req.Method == "GET" && req.URL == "https://discord.com/api/gateway"
|
||||
})).Return(&host.HTTPResponse{StatusCode: 200, Body: gatewayResp}, nil)
|
||||
host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool {
|
||||
return strings.Contains(url, "gateway.discord.gg")
|
||||
}), mock.Anything, "testuser").Return("testuser", nil)
|
||||
@@ -197,17 +197,11 @@ var _ = Describe("discordPlugin", func() {
|
||||
host.SchedulerMock.On("ScheduleRecurring", mock.Anything, payloadHeartbeat, "testuser").Return("testuser", nil)
|
||||
host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil)
|
||||
|
||||
// Image mocks
|
||||
host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool {
|
||||
return strings.HasPrefix(key, "discord.image.")
|
||||
})).Return("", false, nil)
|
||||
host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
// Cache mocks (Discord image processing)
|
||||
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
|
||||
host.CacheMock.On("SetString", discordImageKey, mock.Anything, mock.Anything).Return(nil)
|
||||
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil)
|
||||
assetsReq := &pdk.HTTPRequest{}
|
||||
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.MatchedBy(func(url string) bool {
|
||||
return strings.Contains(url, "external-assets")
|
||||
})).Return(assetsReq)
|
||||
pdk.PDKMock.On("Send", assetsReq).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{"key":"test-key"}`)))
|
||||
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{}`)}, nil)
|
||||
host.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil)
|
||||
|
||||
err := plugin.NowPlaying(scrobbler.NowPlayingRequest{
|
||||
@@ -223,12 +217,13 @@ var _ = Describe("discordPlugin", func() {
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sentPayload).To(ContainSubstring(fmt.Sprintf(`"name":"%s"`, expectedName)))
|
||||
Expect(sentPayload).To(ContainSubstring(fmt.Sprintf(`"status_display_type":%d`, expectedDisplayType)))
|
||||
},
|
||||
Entry("defaults to Navidrome when not configured", "", false, "Navidrome"),
|
||||
Entry("defaults to Navidrome with explicit default value", "Default", true, "Navidrome"),
|
||||
Entry("uses track title when configured", "Track", true, "Test Song"),
|
||||
Entry("uses track album when configured", "Album", true, "Test Album"),
|
||||
Entry("uses track artist when configured", "Artist", true, "Test Artist"),
|
||||
Entry("defaults to Navidrome when not configured", "", false, "Navidrome", 2),
|
||||
Entry("defaults to Navidrome with explicit default value", "Default", true, "Navidrome", 2),
|
||||
Entry("uses track title when configured", "Track", true, "Test Song", 0),
|
||||
Entry("uses track album when configured", "Album", true, "Test Album", 0),
|
||||
Entry("uses track artist when configured", "Artist", true, "Test Artist", 0),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
+25
-3
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://raw.githubusercontent.com/navidrome/navidrome/refs/heads/master/plugins/manifest-schema.json",
|
||||
"name": "Discord Rich Presence",
|
||||
"author": "Navidrome Team",
|
||||
"version": "0.3.0",
|
||||
"version": "1.0.0-beta-1",
|
||||
"description": "Discord Rich Presence integration for Navidrome",
|
||||
"website": "https://github.com/navidrome/discord-rich-presence-plugin",
|
||||
"permissions": {
|
||||
@@ -10,10 +10,12 @@
|
||||
"reason": "To process scrobbles on behalf of users"
|
||||
},
|
||||
"http": {
|
||||
"reason": "To communicate with Discord API for gateway discovery and image uploads",
|
||||
"reason": "To communicate with Discord API, image uploads, and ListenBrainz for track resolution",
|
||||
"requiredHosts": [
|
||||
"discord.com",
|
||||
"uguu.se"
|
||||
"uguu.se",
|
||||
"labs.api.listenbrainz.org",
|
||||
"coverartarchive.org"
|
||||
]
|
||||
},
|
||||
"websocket": {
|
||||
@@ -59,11 +61,23 @@
|
||||
],
|
||||
"default": "Default"
|
||||
},
|
||||
"caaenabled": {
|
||||
"type": "boolean",
|
||||
"title": "Use artwork from Cover Art Archive (for MusicBrainz-tagged music)",
|
||||
"description": "When enabled, attempts to fetch album artwork from the Cover Art Archive using MusicBrainz IDs. Takes priority over other artwork methods.",
|
||||
"default": false
|
||||
},
|
||||
"uguuenabled": {
|
||||
"type": "boolean",
|
||||
"title": "Upload artwork to uguu.se (enable if Navidrome is not publicly accessible)",
|
||||
"default": false
|
||||
},
|
||||
"spotifylinks": {
|
||||
"type": "boolean",
|
||||
"title": "Enable Spotify link-through",
|
||||
"description": "When enabled, clicking the track title or album art in Discord opens the corresponding Spotify page",
|
||||
"default": false
|
||||
},
|
||||
"users": {
|
||||
"type": "array",
|
||||
"title": "User Tokens",
|
||||
@@ -111,10 +125,18 @@
|
||||
"format": "radio"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Control",
|
||||
"scope": "#/properties/caaenabled"
|
||||
},
|
||||
{
|
||||
"type": "Control",
|
||||
"scope": "#/properties/uguuenabled"
|
||||
},
|
||||
{
|
||||
"type": "Control",
|
||||
"scope": "#/properties/spotifylinks"
|
||||
},
|
||||
{
|
||||
"type": "Control",
|
||||
"scope": "#/properties/users",
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func TestDiscordPlugin(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Discord Plugin Main Suite")
|
||||
}
|
||||
|
||||
// Shared matchers for tighter mock expectations across all test files.
|
||||
var (
|
||||
discordImageKey = mock.MatchedBy(func(key string) bool { return strings.HasPrefix(key, "discord.image.") })
|
||||
externalAssetsReq = mock.MatchedBy(func(req host.HTTPRequest) bool { return strings.Contains(req.URL, "external-assets") })
|
||||
spotifyURLKey = mock.MatchedBy(func(key string) bool { return strings.HasPrefix(key, "spotify.url.") })
|
||||
)
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
// This file handles all Discord gateway communication including WebSocket connections,
|
||||
// presence updates, and heartbeat management. The discordRPC struct implements WebSocket
|
||||
// callback interfaces and encapsulates all Discord communication logic.
|
||||
//
|
||||
// References:
|
||||
// - Gateway Events (official): https://docs.discord.com/developers/events/gateway-events
|
||||
// - Activity object (community): https://discord-api-types.dev/api/next/discord-api-types-v10/interface/GatewayActivity
|
||||
// - Presence resources (community): https://docs.discord.food/resources/presence
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -15,16 +20,10 @@ import (
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/websocket"
|
||||
)
|
||||
|
||||
// Discord WebSocket Gateway constants
|
||||
// Image cache TTL constants
|
||||
const (
|
||||
heartbeatOpCode = 1 // Heartbeat operation code
|
||||
gateOpCode = 2 // Identify operation code
|
||||
presenceOpCode = 3 // Presence update operation code
|
||||
)
|
||||
|
||||
const (
|
||||
heartbeatInterval = 41 // Heartbeat interval in seconds
|
||||
defaultImage = "https://i.imgur.com/hb3XPzA.png"
|
||||
imageCacheTTL int64 = 4 * 60 * 60 // 4 hours for track artwork
|
||||
defaultImageCacheTTL int64 = 48 * 60 * 60 // 48 hours for default Navidrome logo
|
||||
)
|
||||
|
||||
// Scheduler callback payloads for routing
|
||||
@@ -36,6 +35,99 @@ const (
|
||||
// discordRPC handles Discord gateway communication and implements WebSocket callbacks.
|
||||
type discordRPC struct{}
|
||||
|
||||
// ============================================================================
|
||||
// Discord types and constants
|
||||
// ============================================================================
|
||||
|
||||
// Discord WebSocket Gateway constants
|
||||
const (
|
||||
heartbeatOpCode = 1 // Heartbeat operation code
|
||||
gateOpCode = 2 // Identify operation code
|
||||
presenceOpCode = 3 // Presence update operation code
|
||||
)
|
||||
|
||||
// Discord status_display_type values control how the activity is shown in the member list.
|
||||
const (
|
||||
statusDisplayName = 0 // Show activity name in member list
|
||||
statusDisplayState = 1 // Show state field in member list
|
||||
statusDisplayDetails = 2 // Show details field in member list
|
||||
)
|
||||
|
||||
const heartbeatInterval = 41 // Heartbeat interval in seconds
|
||||
|
||||
// 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
|
||||
// ============================================================================
|
||||
@@ -63,59 +155,15 @@ func (r *discordRPC) OnClose(input websocket.OnCloseRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// activity represents a Discord activity.
|
||||
type activity struct {
|
||||
Name string `json:"name"`
|
||||
Type int `json:"type"`
|
||||
Details string `json:"details"`
|
||||
State string `json:"state"`
|
||||
Application string `json:"application_id"`
|
||||
Timestamps activityTimestamps `json:"timestamps"`
|
||||
Assets activityAssets `json:"assets"`
|
||||
}
|
||||
|
||||
type activityTimestamps struct {
|
||||
Start int64 `json:"start"`
|
||||
End int64 `json:"end"`
|
||||
}
|
||||
|
||||
type activityAssets struct {
|
||||
LargeImage string `json:"large_image"`
|
||||
LargeText string `json:"large_text"`
|
||||
}
|
||||
|
||||
// presencePayload represents a Discord presence update.
|
||||
type presencePayload struct {
|
||||
Activities []activity `json:"activities"`
|
||||
Since int64 `json:"since"`
|
||||
Status string `json:"status"`
|
||||
Afk bool `json:"afk"`
|
||||
}
|
||||
|
||||
// identifyPayload represents a Discord identify payload.
|
||||
type identifyPayload struct {
|
||||
Token string `json:"token"`
|
||||
Intents int `json:"intents"`
|
||||
Properties identifyProperties `json:"properties"`
|
||||
}
|
||||
|
||||
type identifyProperties struct {
|
||||
OS string `json:"os"`
|
||||
Browser string `json:"browser"`
|
||||
Device string `json:"device"`
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Image Processing
|
||||
// ============================================================================
|
||||
|
||||
// processImage processes an image URL for Discord, with fallback to default image.
|
||||
func (r *discordRPC) processImage(imageURL, clientID, token string, isDefaultImage bool) (string, error) {
|
||||
// processImage processes an image URL for Discord. Returns the processed image
|
||||
// string (mp:prefixed) or an error. No fallback logic — the caller handles retries.
|
||||
func (r *discordRPC) processImage(imageURL, clientID, token string, ttl int64) (string, error) {
|
||||
if imageURL == "" {
|
||||
if isDefaultImage {
|
||||
return "", fmt.Errorf("default image URL is empty")
|
||||
}
|
||||
return r.processImage(defaultImage, clientID, token, true)
|
||||
return "", fmt.Errorf("image URL is empty")
|
||||
}
|
||||
|
||||
if strings.HasPrefix(imageURL, "mp:") {
|
||||
@@ -123,7 +171,7 @@ func (r *discordRPC) processImage(imageURL, clientID, token string, isDefaultIma
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
cacheKey := fmt.Sprintf("discord.image.%x", imageURL)
|
||||
cacheKey := "discord.image." + hashKey(imageURL)
|
||||
cachedValue, exists, err := host.CacheGetString(cacheKey)
|
||||
if err == nil && exists {
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("Cache hit for image URL: %s", imageURL))
|
||||
@@ -132,50 +180,36 @@ func (r *discordRPC) processImage(imageURL, clientID, token string, isDefaultIma
|
||||
|
||||
// Process via Discord API
|
||||
body := fmt.Sprintf(`{"urls":[%q]}`, imageURL)
|
||||
req := pdk.NewHTTPRequest(pdk.MethodPost, fmt.Sprintf("https://discord.com/api/v9/applications/%s/external-assets", clientID))
|
||||
req.SetHeader("Authorization", token)
|
||||
req.SetHeader("Content-Type", "application/json")
|
||||
req.SetBody([]byte(body))
|
||||
|
||||
resp := req.Send()
|
||||
if resp.Status() >= 400 {
|
||||
if isDefaultImage {
|
||||
return "", fmt.Errorf("failed to process default image: HTTP %d", resp.Status())
|
||||
resp, err := host.HTTPSend(host.HTTPRequest{
|
||||
Method: "POST",
|
||||
URL: fmt.Sprintf("https://discord.com/api/v9/applications/%s/external-assets", clientID),
|
||||
Headers: map[string]string{"Authorization": token, "Content-Type": "application/json"},
|
||||
Body: []byte(body),
|
||||
})
|
||||
if err != nil {
|
||||
pdk.Log(pdk.LogWarn, fmt.Sprintf("HTTP request failed for image processing: %v", err))
|
||||
return "", fmt.Errorf("failed to process image: %w", err)
|
||||
}
|
||||
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
|
||||
if err := json.Unmarshal(resp.Body(), &data); err != nil {
|
||||
if isDefaultImage {
|
||||
return "", fmt.Errorf("failed to unmarshal default image response: %w", err)
|
||||
}
|
||||
return r.processImage(defaultImage, clientID, token, true)
|
||||
if err := json.Unmarshal(resp.Body, &data); err != nil {
|
||||
return "", fmt.Errorf("failed to unmarshal image response: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
if isDefaultImage {
|
||||
return "", fmt.Errorf("no data returned for default image")
|
||||
}
|
||||
return r.processImage(defaultImage, clientID, token, true)
|
||||
return "", fmt.Errorf("no data returned for image")
|
||||
}
|
||||
|
||||
image := data[0]["external_asset_path"]
|
||||
if image == "" {
|
||||
if isDefaultImage {
|
||||
return "", fmt.Errorf("empty external_asset_path for default image")
|
||||
}
|
||||
return r.processImage(defaultImage, clientID, token, true)
|
||||
return "", fmt.Errorf("empty external_asset_path for image")
|
||||
}
|
||||
|
||||
processedImage := fmt.Sprintf("mp:%s", image)
|
||||
|
||||
// Cache the processed image URL
|
||||
var ttl int64 = 4 * 60 * 60 // 4 hours for regular images
|
||||
if isDefaultImage {
|
||||
ttl = 48 * 60 * 60 // 48 hours for default image
|
||||
}
|
||||
|
||||
_ = host.CacheSetString(cacheKey, processedImage, ttl)
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("Cached processed image URL for %s (TTL: %ds)", imageURL, ttl))
|
||||
|
||||
@@ -190,12 +224,48 @@ func (r *discordRPC) processImage(imageURL, clientID, token string, isDefaultIma
|
||||
func (r *discordRPC) sendActivity(clientID, username, token string, data activity) error {
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("Sending activity for user %s: %s - %s", username, data.Details, data.State))
|
||||
|
||||
processedImage, err := r.processImage(data.Assets.LargeImage, clientID, token, false)
|
||||
// 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 {
|
||||
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))
|
||||
processedImage, err = r.processImage(navidromeLogoURL, clientID, token, defaultImageCacheTTL)
|
||||
if err != nil {
|
||||
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to process default image for user %s: %v, continuing without image", username, err))
|
||||
data.Assets.LargeImage = ""
|
||||
} else {
|
||||
data.Assets.LargeImage = processedImage
|
||||
usingDefaultImage = true
|
||||
}
|
||||
} else {
|
||||
data.Assets.LargeImage = processedImage
|
||||
}
|
||||
|
||||
// Only show SmallImage (Navidrome logo overlay) when LargeImage is actual track artwork
|
||||
if usingDefaultImage || data.Assets.LargeImage == "" {
|
||||
data.Assets.SmallImage = ""
|
||||
data.Assets.SmallText = ""
|
||||
} else if data.Assets.SmallImage != "" {
|
||||
processedSmall, err := r.processImage(data.Assets.SmallImage, clientID, token, defaultImageCacheTTL)
|
||||
if err != nil {
|
||||
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to process small image for user %s: %v", username, err))
|
||||
data.Assets.SmallImage = ""
|
||||
data.Assets.SmallText = ""
|
||||
} else {
|
||||
data.Assets.SmallImage = processedSmall
|
||||
}
|
||||
}
|
||||
|
||||
presence := presencePayload{
|
||||
@@ -236,14 +306,20 @@ func (r *discordRPC) sendMessage(username string, opCode int, payload any) error
|
||||
|
||||
// getDiscordGateway retrieves the Discord gateway URL.
|
||||
func (r *discordRPC) getDiscordGateway() (string, error) {
|
||||
req := pdk.NewHTTPRequest(pdk.MethodGet, "https://discord.com/api/gateway")
|
||||
resp := req.Send()
|
||||
if resp.Status() != 200 {
|
||||
return "", fmt.Errorf("failed to get Discord gateway: HTTP %d", resp.Status())
|
||||
resp, err := host.HTTPSend(host.HTTPRequest{
|
||||
Method: "GET",
|
||||
URL: "https://discord.com/api/gateway",
|
||||
})
|
||||
if err != nil {
|
||||
pdk.Log(pdk.LogWarn, fmt.Sprintf("HTTP request failed for Discord gateway: %v", err))
|
||||
return "", fmt.Errorf("failed to get Discord gateway: %w", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("failed to get Discord gateway: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result map[string]string
|
||||
if err := json.Unmarshal(resp.Body(), &result); err != nil {
|
||||
if err := json.Unmarshal(resp.Body, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse Discord gateway response: %w", err)
|
||||
}
|
||||
return result["url"], nil
|
||||
|
||||
+301
-17
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
@@ -25,6 +26,8 @@ var _ = Describe("discordRPC", func() {
|
||||
host.WebSocketMock.Calls = nil
|
||||
host.SchedulerMock.ExpectedCalls = nil
|
||||
host.SchedulerMock.Calls = nil
|
||||
host.HTTPMock.ExpectedCalls = nil
|
||||
host.HTTPMock.Calls = nil
|
||||
})
|
||||
|
||||
Describe("sendMessage", func() {
|
||||
@@ -81,9 +84,9 @@ var _ = Describe("discordRPC", func() {
|
||||
|
||||
// Mock HTTP GET request for gateway discovery
|
||||
gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`)
|
||||
httpReq := &pdk.HTTPRequest{}
|
||||
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://discord.com/api/gateway").Return(httpReq)
|
||||
pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, gatewayResp))
|
||||
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
|
||||
return req.Method == "GET" && req.URL == "https://discord.com/api/gateway"
|
||||
})).Return(&host.HTTPResponse{StatusCode: 200, Body: gatewayResp}, nil)
|
||||
|
||||
// Mock WebSocket connection
|
||||
host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool {
|
||||
@@ -202,7 +205,7 @@ var _ = Describe("discordRPC", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
err := r.OnBinaryMessage(websocket.OnBinaryMessageRequest{
|
||||
ConnectionID: "testuser",
|
||||
Data: "AQID", // base64 encoded [0x01, 0x02, 0x03]
|
||||
Data: []byte("AQID"), // base64 encoded [0x01, 0x02, 0x03]
|
||||
})
|
||||
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() {
|
||||
BeforeEach(func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool {
|
||||
return strings.HasPrefix(key, "discord.image.")
|
||||
})).Return("", false, nil)
|
||||
host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
// Mock HTTP request for Discord external assets API (image processing)
|
||||
// When processImage is called, it makes an HTTP request
|
||||
httpReq := &pdk.HTTPRequest{}
|
||||
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(httpReq)
|
||||
pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{"key":"test-key"}`)))
|
||||
})
|
||||
|
||||
It("sends activity update to Discord", func() {
|
||||
It("sends activity with track artwork and SmallImage overlay", func() {
|
||||
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
|
||||
host.CacheMock.On("SetString", discordImageKey, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[{"external_asset_path":"external/art"}]`)}, nil)
|
||||
|
||||
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
|
||||
return strings.Contains(msg, `"op":3`) &&
|
||||
strings.Contains(msg, `"name":"Test Song"`) &&
|
||||
strings.Contains(msg, `"state":"Test Artist"`)
|
||||
strings.Contains(msg, `"large_image":"mp:external/art"`) &&
|
||||
strings.Contains(msg, `"small_image":"mp:external/art"`) &&
|
||||
strings.Contains(msg, `"small_text":"Navidrome"`)
|
||||
})).Return(nil)
|
||||
|
||||
err := r.sendActivity("client123", "testuser", "token123", activity{
|
||||
@@ -260,6 +340,159 @@ var _ = Describe("discordRPC", func() {
|
||||
Type: 2,
|
||||
State: "Test Artist",
|
||||
Details: "Test Album",
|
||||
Assets: activityAssets{
|
||||
LargeImage: "https://example.com/art.jpg",
|
||||
LargeText: "Test Album",
|
||||
SmallImage: navidromeLogoURL,
|
||||
SmallText: "Navidrome",
|
||||
},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("falls back to default image and clears SmallImage", func() {
|
||||
// Track art fails (HTTP error), default image succeeds
|
||||
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
|
||||
host.CacheMock.On("SetString", discordImageKey, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
// First call (track art) returns 500, second call (default) succeeds
|
||||
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 500, Body: []byte(`error`)}, nil).Once()
|
||||
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[{"external_asset_path":"external/logo"}]`)}, nil).Once()
|
||||
|
||||
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
|
||||
return strings.Contains(msg, `"op":3`) &&
|
||||
strings.Contains(msg, `"large_image":"mp:external/logo"`) &&
|
||||
!strings.Contains(msg, `"small_image":"mp:`) &&
|
||||
!strings.Contains(msg, `"small_text":"Navidrome"`)
|
||||
})).Return(nil)
|
||||
|
||||
err := r.sendActivity("client123", "testuser", "token123", activity{
|
||||
Application: "client123",
|
||||
Name: "Test Song",
|
||||
Type: 2,
|
||||
State: "Test Artist",
|
||||
Details: "Test Album",
|
||||
Assets: activityAssets{
|
||||
LargeImage: "https://example.com/art.jpg",
|
||||
LargeText: "Test Album",
|
||||
SmallImage: navidromeLogoURL,
|
||||
SmallText: "Navidrome",
|
||||
},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("clears all images when both track art and default fail", func() {
|
||||
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
|
||||
|
||||
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{"not":"array"}`)}, nil)
|
||||
|
||||
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
|
||||
return strings.Contains(msg, `"op":3`) &&
|
||||
strings.Contains(msg, `"large_image":""`) &&
|
||||
!strings.Contains(msg, `"small_image":"mp:`)
|
||||
})).Return(nil)
|
||||
|
||||
err := r.sendActivity("client123", "testuser", "token123", activity{
|
||||
Application: "client123",
|
||||
Name: "Test Song",
|
||||
Type: 2,
|
||||
State: "Test Artist",
|
||||
Details: "Test Album",
|
||||
Assets: activityAssets{
|
||||
LargeImage: "https://example.com/art.jpg",
|
||||
LargeText: "Test Album",
|
||||
SmallImage: navidromeLogoURL,
|
||||
SmallText: "Navidrome",
|
||||
},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("handles SmallImage processing failure gracefully", func() {
|
||||
// LargeImage from cache (succeeds), SmallImage API fails
|
||||
host.CacheMock.On("GetString", discordImageKey).Return("mp:cached/large", true, nil).Once()
|
||||
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil).Once()
|
||||
|
||||
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 500, Body: []byte(`error`)}, nil)
|
||||
|
||||
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
|
||||
return strings.Contains(msg, `"large_image":"mp:cached/large"`) &&
|
||||
!strings.Contains(msg, `"small_image":"mp:`)
|
||||
})).Return(nil)
|
||||
|
||||
err := r.sendActivity("client123", "testuser", "token123", activity{
|
||||
Application: "client123",
|
||||
Name: "Test Song",
|
||||
Type: 2,
|
||||
State: "Test Artist",
|
||||
Details: "Test Album",
|
||||
Assets: activityAssets{
|
||||
LargeImage: "https://example.com/art.jpg",
|
||||
LargeText: "Test Album",
|
||||
SmallImage: navidromeLogoURL,
|
||||
SmallText: "Navidrome",
|
||||
},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
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())
|
||||
})
|
||||
@@ -276,4 +509,55 @@ var _ = Describe("discordRPC", func() {
|
||||
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
@@ -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
@@ -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