33 Commits

Author SHA1 Message Date
atridad 7de0568bc7 Update .gitignore
Test / Test (push) Has been cancelled
2026-04-02 15:56:37 -06:00
atridad 383789a6a5 Remove .devenv 2026-04-02 15:56:17 -06:00
atridad a903d6cdea Merge pull request 'Custom Activity Name Templates' (#23) from atridadl/main into main
Test / Test (push) Has been cancelled
Reviewed-on: #23
2026-04-02 15:48:21 -06:00
atridad fa381fbc83 Merge
Test / Test (pull_request) Has been cancelled
2026-04-02 15:46:58 -06:00
atridad 87366781f5 Re-branding the fork
Test / Test (push) Failing after 5m27s
2026-04-02 13:01:16 -06:00
atridad 41cf2971c1 Merge branch 'main' into main 2026-04-02 15:34:34 +00:00
Deluan Quintão 4e0f98aa51 Update Navidrome version requirement to 0.61.0 2026-03-31 19:34:06 -04:00
github-actions[bot] e0f3361051 Release v1.0.0 2026-03-31 23:25:33 +00:00
github-actions[bot] 47b444d72a Release v1.0.0-beta-1 2026-03-21 01:35:12 +00:00
deluan 323bf7089a ci: update release workflow to use beta versioning instead of prerelease 2026-03-20 21:32:25 -04:00
deluan 24615fda7b docs: add config screenshot and convert images to WebP 2026-03-20 21:05:47 -04:00
deluan 5f57906aca docs: add Cover Art Archive documentation to README 2026-03-20 20:48:12 -04:00
Deluan Quintão 606a7f2389 feat: use Cover Art Archive for albums with MusicBrainz IDs (#27) 2026-03-20 20:34:11 -04:00
atridad 414021f471 Addressing gemini's suggestion 2026-03-09 23:28:41 -06:00
atridad 24fb4cf752 added custom activity name template 2026-03-09 18:01:58 -06:00
deluan 9d9dce052a fix: update data type for OnBinaryMessageRequest to byte slice 2026-03-04 14:27:35 -05:00
deluan c7c484c714 build: update dependencies for navidrome plugins and pprof 2026-03-04 13:58:14 -05:00
github-actions[bot] 4600172dbf Release v1.0.0-prerelease 2026-03-04 18:21:24 +00:00
Deluan Quintão 3ce0277ecb Add pre-release support to release process (#19) 2026-03-04 13:20:46 -05:00
Deluan Quintão 6d7448f3ee fix: truncate long activity fields to prevent Discord rejection (#18) 2026-03-04 12:48:56 -05:00
deluan c22b950be3 docs: update screenshot 2026-03-04 12:09:12 -05:00
WoahAI f96884e3e5 Spotify Link-Through & Navidrome Logo Overlay (#15)
Co-authored-by: deluan <deluan@deluan.com>
2026-03-04 12:04:03 -05:00
deluan 1552322429 build: add support for Standard Go in Makefile and update .gitignore 2026-02-23 20:55:34 -05:00
deluan 8d07bc6120 ci: also append git SHA to version on push-to-main builds 2026-02-09 14:32:25 -05:00
Deluan Quintão 1a236fd00f ci: append git SHA to manifest version in PR builds (#14) 2026-02-09 14:28:18 -05:00
deluan 223ebf0539 docs: correct attention note for Navidrome version requirement in README 2026-02-08 13:23:09 -05:00
deluan 05714ace50 docs: update user configuration instructions for Discord Rich Presence 2026-02-08 13:05:35 -05:00
github-actions[bot] 4700e15a3c Release v0.3.0 2026-02-08 17:51:29 +00:00
Daniel Stefani 6b5ca1a54f feat: add configuration option to select Activity Name based on currently playing track (#11) 2026-02-08 12:50:20 -05:00
deluan 24c4c36651 Update README.md 2026-02-07 21:11:02 -05:00
github-actions[bot] 7e94c83a12 Release v0.2.2 2026-02-08 02:06:24 +00:00
deluan b916c4c8fd Remove release.yml in favor of create-release workflow
Releases are now fully handled by create-release.yml, which is
triggered manually via the GitHub UI or `make release`. The separate
tag-triggered release.yml is no longer needed.
2026-02-07 21:05:05 -05:00
deluan 35fbcbb46e Add build and release steps to create-release workflow
Move the TinyGo build, packaging, and GitHub release creation from
release.yml into create-release.yml. This avoids the GITHUB_TOKEN
limitation where pushes from a workflow don't trigger other workflows.
release.yml is kept as a fallback for manually pushed tags.
2026-02-07 21:04:00 -05:00
24 changed files with 1766 additions and 463 deletions
+1
View File
@@ -0,0 +1 @@
use flake
@@ -1,4 +1,4 @@
name: Build
name: Test
on:
pull_request:
@@ -9,8 +9,8 @@ on:
- main
jobs:
build:
name: Build
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Check out code
@@ -29,12 +29,3 @@ jobs:
- name: Run tests
run: make test
- name: Build and package plugin
run: make package
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: discord-rich-presence
path: discord-rich-presence.ndp
Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

-63
View File
@@ -1,63 +0,0 @@
name: Create Release
on:
workflow_dispatch:
inputs:
version:
description: "Release version (e.g., 1.2.3, without the 'v' prefix)"
required: true
type: string
permissions:
contents: write
jobs:
create-release:
name: Create Release Tag
runs-on: ubuntu-latest
steps:
- name: Validate version format
env:
VERSION: ${{ inputs.version }}
run: |
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: 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"
exit 1
fi
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Run tests
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"
git add manifest.json
git commit --allow-empty -m "Release v${VERSION}"
git tag "v${VERSION}"
git push origin main "v${VERSION}"
-50
View File
@@ -1,50 +0,0 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v5
- name: Verify manifest version matches tag
run: |
TAG_VERSION="${GITHUB_REF_NAME#v}"
MANIFEST_VERSION=$(jq -r .version manifest.json)
if [ "$TAG_VERSION" != "$MANIFEST_VERSION" ]; then
echo "::error::Tag version ($TAG_VERSION) does not match manifest.json version ($MANIFEST_VERSION)"
exit 1
fi
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install TinyGo
run: |
wget https://github.com/tinygo-org/tinygo/releases/download/v0.40.1/tinygo_0.40.1_amd64.deb
sudo dpkg -i tinygo_0.40.1_amd64.deb
sudo apt install -y binaryen
- name: Run tests
run: make test
- name: Build and package plugin
run: make package
- name: Create release
uses: softprops/action-gh-release@v2
with:
draft: true
files: discord-rich-presence.ndp
generate_release_notes: true
-38
View File
@@ -1,38 +0,0 @@
name: Add download link to PR
on:
workflow_run:
workflows: ["Build"]
types: [completed]
jobs:
pr_comment:
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Get PR number
id: pr
uses: actions/github-script@v7
with:
script: |
const pulls = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open'
});
const pr = pulls.data.find(p => p.head.sha === '${{ github.event.workflow_run.head_sha }}');
if (pr) {
core.setOutput('number', pr.number);
}
- name: Add download link comment
if: steps.pr.outputs.number
uses: marocchino/sticky-pull-request-comment@v2
with:
number: ${{ steps.pr.outputs.number }}
message: |
Download the plugin for this PR: [discord-rich-presence.zip](https://nightly.link/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}/discord-rich-presence.zip)
Built from ${{ github.event.workflow_run.head_sha }} on ${{ github.event.workflow_run.updated_at }}
+5 -1
View File
@@ -1,3 +1,7 @@
*.wasm
*.ndp
tmp
tmp
discordrome
.DS_Store
.devenv
+11 -5
View File
@@ -1,14 +1,20 @@
SHELL := /usr/bin/env bash
.PHONY: test build package clean
PLUGIN_NAME := discord-rich-presence
PLUGIN_NAME := discodrome
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
+109 -81
View File
@@ -1,31 +1,34 @@
# Discord Rich Presence Plugin for Navidrome
# Discodrome, a work of Discord Rich Presence Plugin for Navidrome
[![Build](https://github.com/navidrome/discord-rich-presence-plugin/actions/workflows/build.yml/badge.svg)](https://github.com/navidrome/discord-rich-presence-plugin/actions/workflows/build.yml)
[![Latest](https://img.shields.io/github/v/release/navidrome/discord-rich-presence-plugin)](https://github.com/navidrome/discord-rich-presence-plugin/releases/latest/download/discord-rich-presence.ndp)
**Attention: This plugin requires Navidrome 0.61.0 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.
Based on the [Navicord](https://github.com/logixism/navicord) project.
Based on the [discord-rich-presence-plugin](https://github.com/navidrome/discord-rich-presence-plugin) project.
**⚠️ WARNING: This plugin requires storing Discord user tokens, which may violate Discord's Terms of Service. Use at your own risk.**
## 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">
## Installation
### Step 1: Download and Install the Plugin
1. Download the `discord-rich-presence.ndp` file from the [releases page](https://github.com/navidrome/discord-rich-presence-plugin/releases)
1. Download the `discodrome.ndp` file from the [releases page](https://git.atri.dad/atridad/discodrome/releases)
2. Copy it to your Navidrome plugins folder. Default location: `<navidrome-data-directory>/plugins/`
### Step 2: Create a Discord Application
@@ -42,7 +45,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
@@ -59,11 +66,6 @@ In Discord, ensure your activity is visible to others:
For album artwork to display in Discord, Discord needs to be able to access the image. Choose one of these options:
### Decision Guide
**Is your Navidrome accessible from the internet?**
-**YES** → Use Option 1 (Public Instance)
-**NO** (local network, VPN, private server) → Use Option 2 (uguu.se Upload)
### Option 1: Public Navidrome Instance
**Use this if**: Your Navidrome server can be reached from the internet
@@ -79,7 +81,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**:
@@ -91,8 +104,52 @@ 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**
### 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
@@ -107,14 +164,14 @@ The plugin implements three Navidrome capabilities:
### Host Services
| Service | Usage |
|-----------------|---------------------------------------------------------------------|
| **HTTP** | Discord API calls (gateway discovery, external assets registration) |
| **WebSocket** | Persistent connection to Discord gateway |
| **Cache** | Sequence numbers, processed image URLs |
| **Scheduler** | Recurring heartbeats, one-time presence clearing |
| **Artwork** | Track artwork public URL resolution |
| **SubsonicAPI** | Fetches track artwork data for image hosting upload |
| Service | Usage |
|-----------------|------------------------------------------------------------------------------------------------------|
| **HTTP** | Discord API calls (gateway discovery, external assets registration), Cover Art Archive lookups, ListenBrainz Spotify resolution |
| **WebSocket** | Persistent connection to Discord gateway |
| **Cache** | Sequence numbers, processed image URLs, resolved Spotify URLs |
| **Scheduler** | Recurring heartbeats, one-time presence clearing |
| **Artwork** | Track artwork public URL resolution |
| **SubsonicAPI** | Fetches track artwork data for image hosting upload |
### Flow
@@ -136,66 +193,38 @@ 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 |
| [rpc.go](rpc.go) | Discord gateway communication, WebSocket handling, activity management |
| [coverart.go](coverart.go) | Artwork URL handling and optional uguu.se image hosting |
| [manifest.json](manifest.json) | Plugin metadata and permission declarations |
| [Makefile](Makefile) | Build automation |
## 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`
#### 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)
| File | Description |
|----------------------------------|-------------------------------------------------------------------------------------|
| [main.go](main.go) | Plugin entry point, scrobbler and scheduler implementations, Spotify URL resolution |
| [rpc.go](rpc.go) | Discord gateway communication, WebSocket handling, activity management |
| [coverart.go](coverart.go) | Artwork URL handling, Cover Art Archive lookups, and optional uguu.se image hosting |
| [manifest.json](manifest.json) | Plugin metadata and permission declarations |
| [Makefile](Makefile) | Build automation |
## Building
@@ -215,7 +244,7 @@ make build
make package
```
The `make package` command creates `discord-rich-presence.ndp` containing the compiled WebAssembly module and manifest.
The `make package` command creates `discodrome.ndp` containing the compiled WebAssembly module and manifest.
### Manual Build Options
@@ -223,16 +252,15 @@ The `make package` command creates `discord-rich-presence.ndp` containing the co
```sh
# Install TinyGo first: https://tinygo.org/getting-started/install/
tinygo build -target wasip1 -buildmode=c-shared -o plugin.wasm -scheduler=none .
zip discord-rich-presence.ndp plugin.wasm manifest.json
zip discodrome.ndp plugin.wasm manifest.json
```
#### Using Standard Go
```sh
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm .
zip discord-rich-presence.ndp plugin.wasm manifest.json
zip discodrome.ndp plugin.wasm manifest.json
```
### Output
- `plugin.wasm`: The compiled WebAssembly module
- `discord-rich-presence.ndp`: The complete plugin package ready for installation
- `discodrome.ndp`: The complete plugin package ready for installation
+112 -15
View File
@@ -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
View File
@@ -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)
})
})
Generated
+61
View File
@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1775036866,
"narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "6201e203d09599479a3b3450ed24fa81537ebc4e",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+32
View File
@@ -0,0 +1,32 @@
{
description = "Discodrome Development Environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs =
{
nixpkgs,
flake-utils,
self,
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
go
tinygo
gnumake
binaryen
jq
];
};
}
);
}
+12 -10
View File
@@ -1,9 +1,9 @@
module discord-rich-presence
module discodrome
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
)
+23 -20
View File
@@ -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=
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+71 -8
View File
@@ -25,8 +25,30 @@ import (
// Configuration keys
const (
clientIDKey = "clientid"
usersKey = "users"
clientIDKey = "clientid"
usersKey = "users"
activityNameKey = "activityname"
activityNameTemplateKey = "activitynametemplate"
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
const (
activityNameDefault = "Default"
activityNameTrack = "Track"
activityNameArtist = "Artist"
activityNameAlbum = "Album"
activityNameCustom = "Custom"
)
// userToken represents a user-token mapping from the config
@@ -136,20 +158,61 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error {
startTime := (now - int64(input.Position)) * 1000
endTime := startTime + int64(input.Track.Duration)*1000
// 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
case activityNameCustom:
template, _ := pdk.GetConfig(activityNameTemplateKey)
if template != "" {
r := strings.NewReplacer(
"{track}", input.Track.Title,
"{artist}", input.Track.Artist,
"{album}", input.Track.Album,
)
activityName = r.Replace(template)
}
}
// Resolve Spotify URLs if enabled
var spotifyURL, artistSearchURL string
spotifyLinksOption, _ := pdk.GetConfig(spotifyLinksKey)
if spotifyLinksOption == "true" {
spotifyURL = resolveSpotifyURL(input.Track)
artistSearchURL = spotifySearchURL(input.Track.Artist)
}
// Send activity update
if err := rpc.sendActivity(clientID, input.Username, userToken, activity{
Application: clientID,
Name: "Navidrome",
Type: 2, // Listening
Details: input.Track.Title,
State: input.Track.Artist,
Application: clientID,
Name: activityName,
Type: 2, // Listening
Details: input.Track.Title,
DetailsURL: spotifyURL,
State: input.Track.Artist,
StateURL: artistSearchURL,
StatusDisplayType: statusDisplayType,
Timestamps: activityTimestamps{
Start: startTime,
End: endTime,
},
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)
+126 -14
View File
@@ -2,6 +2,7 @@ package main
import (
"errors"
"fmt"
"strings"
"github.com/navidrome/navidrome/plugins/pdk/go/host"
@@ -32,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() {
@@ -119,15 +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 {
@@ -139,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)
@@ -169,6 +169,118 @@ var _ = Describe("discordPlugin", func() {
})
Expect(err).ToNot(HaveOccurred())
})
DescribeTable("activity name configuration",
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"}`)
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)
// Capture the activity payload sent to Discord
var sentPayload string
host.WebSocketMock.On("SendText", "testuser", mock.Anything).Run(func(args mock.Arguments) {
sentPayload = args.Get(1).(string)
}).Return(nil)
host.SchedulerMock.On("ScheduleRecurring", mock.Anything, payloadHeartbeat, "testuser").Return("testuser", nil)
host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil)
// Cache mocks (Discord image processing)
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
host.CacheMock.On("SetString", discordImageKey, mock.Anything, mock.Anything).Return(nil)
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil)
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{}`)}, nil)
host.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil)
err := plugin.NowPlaying(scrobbler.NowPlayingRequest{
Username: "testuser",
Position: 10,
Track: scrobbler.TrackInfo{
ID: "track1",
Title: "Test Song",
Artist: "Test Artist",
Album: "Test Album",
Duration: 180,
},
})
Expect(err).ToNot(HaveOccurred())
Expect(sentPayload).To(ContainSubstring(fmt.Sprintf(`"name":"%s"`, expectedName)))
Expect(sentPayload).To(ContainSubstring(fmt.Sprintf(`"status_display_type":%d`, expectedDisplayType)))
},
Entry("defaults to Navidrome when not configured", "", false, "Navidrome", 2),
Entry("defaults to Navidrome with explicit default value", "Default", true, "Navidrome", 2),
Entry("uses track title when configured", "Track", true, "Test Song", 0),
Entry("uses track album when configured", "Album", true, "Test Album", 0),
Entry("uses track artist when configured", "Artist", true, "Test Artist", 0),
)
DescribeTable("custom activity name template",
func(template string, templateExists bool, expectedName string) {
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true)
pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"testuser","token":"test-token"}]`, true)
pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false)
pdk.PDKMock.On("GetConfig", activityNameKey).Return("Custom", true)
pdk.PDKMock.On("GetConfig", activityNameTemplateKey).Return(template, templateExists)
pdk.PDKMock.On("GetConfig", spotifyLinksKey).Return("", false)
// Connect mocks
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found"))
gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`)
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
return req.Method == "GET" && req.URL == "https://discord.com/api/gateway"
})).Return(&host.HTTPResponse{StatusCode: 200, Body: gatewayResp}, nil)
host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool {
return strings.Contains(url, "gateway.discord.gg")
}), mock.Anything, "testuser").Return("testuser", nil)
// Capture the activity payload sent to Discord
var sentPayload string
host.WebSocketMock.On("SendText", "testuser", mock.Anything).Run(func(args mock.Arguments) {
sentPayload = args.Get(1).(string)
}).Return(nil)
host.SchedulerMock.On("ScheduleRecurring", mock.Anything, payloadHeartbeat, "testuser").Return("testuser", nil)
host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil)
// Image mocks
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
host.CacheMock.On("SetString", discordImageKey, mock.Anything, mock.Anything).Return(nil)
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil)
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{}`)}, nil)
host.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil)
err := plugin.NowPlaying(scrobbler.NowPlayingRequest{
Username: "testuser",
Position: 10,
Track: scrobbler.TrackInfo{
ID: "track1",
Title: "Test Song",
Artist: "Test Artist",
Album: "Test Album",
Duration: 180,
},
})
Expect(err).ToNot(HaveOccurred())
Expect(sentPayload).To(ContainSubstring(fmt.Sprintf(`"name":"%s"`, expectedName)))
},
Entry("uses custom template with all placeholders", "{artist} - {track} ({album})", true, "Test Artist - Test Song (Test Album)"),
Entry("uses custom template with only track", "{track}", true, "Test Song"),
Entry("uses custom template with only artist", "{artist}", true, "Test Artist"),
Entry("uses custom template with only album", "{album}", true, "Test Album"),
Entry("uses custom template with plain text", "Now Playing", true, "Now Playing"),
Entry("falls back to Navidrome when template is empty", "", false, "Navidrome"),
)
})
Describe("Scrobble", func() {
+64 -17
View File
@@ -1,26 +1,26 @@
{
"$schema": "https://raw.githubusercontent.com/navidrome/navidrome/refs/heads/master/plugins/manifest-schema.json",
"name": "Discord Rich Presence",
"author": "Navidrome Team",
"version": "0.2.1",
"name": "Discodrome",
"author": "Atridad Lahiji",
"version": "1.0.0",
"description": "Discord Rich Presence integration for Navidrome",
"website": "https://github.com/navidrome/discord-rich-presence-plugin",
"website": "https://git.atri.dad.com/atridad/discodrome",
"permissions": {
"users": {
"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": {
"reason": "To maintain real-time connection with Discord gateway",
"requiredHosts": [
"gateway.discord.gg"
]
"requiredHosts": ["gateway.discord.gg"]
},
"cache": {
"reason": "To store connection state and sequence numbers"
@@ -47,11 +47,36 @@
"maxLength": 20,
"pattern": "^[0-9]+$"
},
"activityname": {
"type": "string",
"title": "Activity Name Display",
"description": "Choose what to display as the activity name in Discord Rich Presence",
"enum": ["Default", "Track", "Album", "Artist", "Custom"],
"default": "Default"
},
"activitynametemplate": {
"type": "string",
"title": "Custom Activity Name Template",
"description": "Template for the activity name. Available placeholders: {track}, {artist}, {album}",
"default": "{artist} - {track}"
},
"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",
@@ -73,17 +98,11 @@
"minLength": 1
}
},
"required": [
"username",
"token"
]
"required": ["username", "token"]
}
}
},
"required": [
"clientid",
"users"
]
"required": ["clientid", "users"]
},
"uiSchema": {
"type": "VerticalLayout",
@@ -92,10 +111,38 @@
"type": "Control",
"scope": "#/properties/clientid"
},
{
"type": "Control",
"scope": "#/properties/activityname",
"options": {
"format": "radio"
}
},
{
"type": "Control",
"scope": "#/properties/activitynametemplate",
"rule": {
"effect": "SHOW",
"condition": {
"scope": "#/properties/activityname",
"schema": {
"const": "Custom"
}
}
}
},
{
"type": "Control",
"scope": "#/properties/caaenabled"
},
{
"type": "Control",
"scope": "#/properties/uguuenabled"
},
{
"type": "Control",
"scope": "#/properties/spotifylinks"
},
{
"type": "Control",
"scope": "#/properties/users",
+10
View File
@@ -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.") })
)
+172 -96
View File
@@ -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())
}
return r.processImage(defaultImage, clientID, token, true)
resp, err := host.HTTPSend(host.HTTPRequest{
Method: "POST",
URL: fmt.Sprintf("https://discord.com/api/v9/applications/%s/external-assets", clientID),
Headers: map[string]string{"Authorization": token, "Content-Type": "application/json"},
Body: []byte(body),
})
if err != nil {
pdk.Log(pdk.LogWarn, fmt.Sprintf("HTTP request failed for image processing: %v", err))
return "", fmt.Errorf("failed to process image: %w", err)
}
if resp.StatusCode >= 400 {
return "", fmt.Errorf("failed to process image: HTTP %d", resp.StatusCode)
}
var data []map[string]string
if err := json.Unmarshal(resp.Body(), &data); err != nil {
if isDefaultImage {
return "", fmt.Errorf("failed to unmarshal default image response: %w", err)
}
return r.processImage(defaultImage, clientID, token, true)
if err := json.Unmarshal(resp.Body, &data); err != nil {
return "", fmt.Errorf("failed to unmarshal image response: %w", err)
}
if len(data) == 0 {
if isDefaultImage {
return "", fmt.Errorf("no data returned for default image")
}
return r.processImage(defaultImage, clientID, token, true)
return "", fmt.Errorf("no data returned for image")
}
image := data[0]["external_asset_path"]
if image == "" {
if isDefaultImage {
return "", fmt.Errorf("empty external_asset_path for default image")
}
return r.processImage(defaultImage, clientID, token, true)
return "", fmt.Errorf("empty external_asset_path for image")
}
processedImage := fmt.Sprintf("mp:%s", image)
// Cache the processed image URL
var ttl int64 = 4 * 60 * 60 // 4 hours for regular images
if isDefaultImage {
ttl = 48 * 60 * 60 // 48 hours for default image
}
_ = host.CacheSetString(cacheKey, processedImage, ttl)
pdk.Log(pdk.LogDebug, fmt.Sprintf("Cached processed image URL for %s (TTL: %ds)", imageURL, ttl))
@@ -190,14 +224,50 @@ 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))
data.Assets.LargeImage = ""
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to process track image for user %s: %v, falling back to default", username, err))
processedImage, err = r.processImage(navidromeLogoURL, clientID, token, defaultImageCacheTTL)
if err != nil {
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to process default image for user %s: %v, continuing without image", username, err))
data.Assets.LargeImage = ""
} else {
data.Assets.LargeImage = processedImage
usingDefaultImage = true
}
} else {
data.Assets.LargeImage = processedImage
}
// Only show SmallImage (Navidrome logo overlay) when LargeImage is actual track artwork
if usingDefaultImage || data.Assets.LargeImage == "" {
data.Assets.SmallImage = ""
data.Assets.SmallText = ""
} else if data.Assets.SmallImage != "" {
processedSmall, err := r.processImage(data.Assets.SmallImage, clientID, token, defaultImageCacheTTL)
if err != nil {
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to process small image for user %s: %v", username, err))
data.Assets.SmallImage = ""
data.Assets.SmallText = ""
} else {
data.Assets.SmallImage = processedSmall
}
}
presence := presencePayload{
Activities: []activity{data},
Status: "dnd",
@@ -236,14 +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
View File
@@ -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
View File
@@ -0,0 +1,180 @@
package main
import (
"encoding/json"
"fmt"
"net/url"
"strings"
"github.com/navidrome/navidrome/plugins/pdk/go/host"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
"github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
)
// hashKey returns a hex-encoded FNV-1a hash of s, for use as a cache key suffix.
func hashKey(s string) string {
const offset64 uint64 = 14695981039346656037
const prime64 uint64 = 1099511628211
h := offset64
for i := 0; i < len(s); i++ {
h ^= uint64(s[i])
h *= prime64
}
return fmt.Sprintf("%016x", h)
}
const (
spotifyCacheTTLHit int64 = 30 * 24 * 60 * 60 // 30 days for resolved track IDs
spotifyCacheTTLMiss int64 = 4 * 60 * 60 // 4 hours for misses (retry later)
)
// listenBrainzResult captures the relevant field from ListenBrainz Labs JSON responses.
// The API returns spotify_track_ids as an array of strings.
type listenBrainzResult struct {
SpotifyTrackIDs []string `json:"spotify_track_ids"`
}
// spotifySearchURL builds a Spotify search URL from one or more terms.
// Empty terms are ignored. Returns "" if all terms are empty.
func spotifySearchURL(terms ...string) string {
query := strings.TrimSpace(strings.Join(terms, " "))
if query == "" {
return ""
}
return "https://open.spotify.com/search/" + url.PathEscape(query)
}
// spotifyCacheKey returns a deterministic cache key for a track's Spotify URL.
func spotifyCacheKey(artist, title, album string) string {
return "spotify.url." + hashKey(strings.ToLower(artist)+"\x00"+strings.ToLower(title)+"\x00"+strings.ToLower(album))
}
// trySpotifyFromMBID calls the ListenBrainz spotify-id-from-mbid endpoint.
func trySpotifyFromMBID(mbid string) string {
body := fmt.Sprintf(`[{"recording_mbid":%q}]`, mbid)
resp, err := host.HTTPSend(host.HTTPRequest{
Method: "POST",
URL: "https://labs.api.listenbrainz.org/spotify-id-from-mbid/json",
Headers: map[string]string{"Content-Type": "application/json"},
Body: []byte(body),
})
if err != nil {
pdk.Log(pdk.LogInfo, fmt.Sprintf("ListenBrainz MBID lookup request failed: %v", err))
return ""
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz MBID lookup failed: HTTP %d, body=%s", resp.StatusCode, string(resp.Body)))
return ""
}
id := parseSpotifyID(resp.Body)
if id == "" {
pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz MBID lookup returned no spotify_track_id for mbid=%s, body=%s", mbid, string(resp.Body)))
}
return id
}
// trySpotifyFromMetadata calls the ListenBrainz spotify-id-from-metadata endpoint.
func trySpotifyFromMetadata(artist, title, album string) string {
payload := fmt.Sprintf(`[{"artist_name":%q,"track_name":%q,"release_name":%q}]`, artist, title, album)
pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz metadata request: %s", payload))
resp, err := host.HTTPSend(host.HTTPRequest{
Method: "POST",
URL: "https://labs.api.listenbrainz.org/spotify-id-from-metadata/json",
Headers: map[string]string{"Content-Type": "application/json"},
Body: []byte(payload),
})
if err != nil {
pdk.Log(pdk.LogInfo, fmt.Sprintf("ListenBrainz metadata lookup request failed: %v", err))
return ""
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz metadata lookup failed: HTTP %d, body=%s", resp.StatusCode, string(resp.Body)))
return ""
}
pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz metadata response: HTTP %d, body=%s", resp.StatusCode, string(resp.Body)))
id := parseSpotifyID(resp.Body)
if id == "" {
pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz metadata returned no spotify_track_id for %q - %q", artist, title))
}
return id
}
// parseSpotifyID extracts the first spotify track ID from a ListenBrainz Labs JSON response.
// The response is an array of objects with spotify_track_ids arrays; we take the first non-empty ID.
func parseSpotifyID(body []byte) string {
var results []listenBrainzResult
if err := json.Unmarshal(body, &results); err != nil {
return ""
}
for _, r := range results {
for _, id := range r.SpotifyTrackIDs {
if isValidSpotifyID(id) {
return id
}
}
}
return ""
}
// isValidSpotifyID checks that a Spotify track ID is non-empty and contains only base-62 characters.
func isValidSpotifyID(id string) bool {
if len(id) == 0 {
return false
}
for i := 0; i < len(id); i++ {
c := id[i]
if !((c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) {
return false
}
}
return true
}
// resolveSpotifyURL resolves a direct Spotify track URL via ListenBrainz Labs,
// falling back to a search URL. Results are cached.
func resolveSpotifyURL(track scrobbler.TrackInfo) string {
var primary string
if len(track.Artists) > 0 {
primary = track.Artists[0].Name
}
cacheKey := spotifyCacheKey(primary, track.Title, track.Album)
if cached, exists, err := host.CacheGetString(cacheKey); err == nil && exists {
pdk.Log(pdk.LogDebug, fmt.Sprintf("Spotify URL cache hit for %q - %q → %s", primary, track.Title, cached))
return cached
}
pdk.Log(pdk.LogDebug, fmt.Sprintf("Resolving Spotify URL for: artist=%q title=%q album=%q mbid=%q", primary, track.Title, track.Album, track.MBZRecordingID))
// 1. Try MBID lookup (most accurate)
if track.MBZRecordingID != "" {
if trackID := trySpotifyFromMBID(track.MBZRecordingID); trackID != "" {
directURL := "https://open.spotify.com/track/" + trackID
_ = host.CacheSetString(cacheKey, directURL, spotifyCacheTTLHit)
pdk.Log(pdk.LogInfo, fmt.Sprintf("Resolved Spotify via MBID for %q: %s", track.Title, directURL))
return directURL
}
pdk.Log(pdk.LogDebug, "MBID lookup did not return a Spotify ID, trying metadata…")
} else {
pdk.Log(pdk.LogDebug, "No MBZRecordingID available, skipping MBID lookup")
}
// 2. Try metadata lookup
if primary != "" && track.Title != "" {
if trackID := trySpotifyFromMetadata(primary, track.Title, track.Album); trackID != "" {
directURL := "https://open.spotify.com/track/" + trackID
_ = host.CacheSetString(cacheKey, directURL, spotifyCacheTTLHit)
pdk.Log(pdk.LogInfo, fmt.Sprintf("Resolved Spotify via metadata for %q - %q: %s", primary, track.Title, directURL))
return directURL
}
}
// 3. Fallback to search URL
searchURL := spotifySearchURL(track.Artist, track.Title)
_ = host.CacheSetString(cacheKey, searchURL, spotifyCacheTTLMiss)
pdk.Log(pdk.LogInfo, fmt.Sprintf("Spotify resolution missed, falling back to search URL for %q - %q: %s", primary, track.Title, searchURL))
return searchURL
}
+219
View File
@@ -0,0 +1,219 @@
package main
import (
"encoding/json"
"fmt"
"github.com/navidrome/navidrome/plugins/pdk/go/host"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
"github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
"github.com/stretchr/testify/mock"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Spotify", func() {
Describe("spotifySearchURL", func() {
DescribeTable("constructs Spotify search URL",
func(expectedURL string, terms ...string) {
Expect(spotifySearchURL(terms...)).To(Equal(expectedURL))
},
Entry("artist and title", "https://open.spotify.com/search/Rick%20Astley%20Never%20Gonna%20Give%20You%20Up", "Rick Astley", "Never Gonna Give You Up"),
Entry("single term", "https://open.spotify.com/search/Radiohead", "Radiohead"),
Entry("empty terms", "", "", ""),
Entry("one empty term", "https://open.spotify.com/search/Solo%20Artist", "Solo Artist", ""),
)
})
Describe("spotifyCacheKey", func() {
It("produces identical keys for identical inputs", func() {
key1 := spotifyCacheKey("Radiohead", "Karma Police", "OK Computer")
key2 := spotifyCacheKey("Radiohead", "Karma Police", "OK Computer")
Expect(key1).To(Equal(key2))
})
It("produces different keys for different albums", func() {
key1 := spotifyCacheKey("Radiohead", "Karma Police", "OK Computer")
key2 := spotifyCacheKey("Radiohead", "Karma Police", "The Bends")
Expect(key1).ToNot(Equal(key2))
})
It("uses the correct prefix", func() {
key := spotifyCacheKey("Radiohead", "Karma Police", "OK Computer")
Expect(key).To(HavePrefix("spotify.url."))
})
It("is case-insensitive", func() {
keyUpper := spotifyCacheKey("Radiohead", "Karma Police", "OK Computer")
keyLower := spotifyCacheKey("radiohead", "karma police", "ok computer")
Expect(keyUpper).To(Equal(keyLower))
})
})
Describe("parseSpotifyID", func() {
DescribeTable("extracts first Spotify track ID from ListenBrainz response",
func(body, expectedID string) {
Expect(parseSpotifyID([]byte(body))).To(Equal(expectedID))
},
Entry("valid single result",
`[{"spotify_track_ids":["4tIGK5G9hNDA50ZdGioZRG"]}]`, "4tIGK5G9hNDA50ZdGioZRG"),
Entry("multiple IDs picks first",
`[{"artist_name":"Lil Baby & Drake","track_name":"Yes Indeed","spotify_track_ids":["6vN77lE9LK6HP2DewaN6HZ","4wlLbLeDWbA6TzwZFp1UaK"]}]`, "6vN77lE9LK6HP2DewaN6HZ"),
Entry("valid result with extra fields",
`[{"artist_name":"Radiohead","track_name":"Karma Police","spotify_track_ids":["63OQupATfueTdZMWIV7nzz"],"release_name":"OK Computer"}]`, "63OQupATfueTdZMWIV7nzz"),
Entry("empty spotify_track_ids array",
`[{"spotify_track_ids":[]}]`, ""),
Entry("no spotify_track_ids field",
`[{"artist_name":"Unknown"}]`, ""),
Entry("empty array",
`[]`, ""),
Entry("invalid JSON",
`not json`, ""),
Entry("null first result falls through to second",
`[{"spotify_track_ids":[]},{"spotify_track_ids":["6vN77lE9LK6HP2DewaN6HZ"]}]`, "6vN77lE9LK6HP2DewaN6HZ"),
Entry("skips invalid ID with special characters",
`[{"spotify_track_ids":["abc!@#$%^&*()_+=-12345"]}]`, ""),
)
})
Describe("isValidSpotifyID", func() {
DescribeTable("validates Spotify track IDs",
func(id string, expected bool) {
Expect(isValidSpotifyID(id)).To(Equal(expected))
},
Entry("valid 22-char ID", "6vN77lE9LK6HP2DewaN6HZ", true),
Entry("another valid ID", "4tIGK5G9hNDA50ZdGioZRG", true),
Entry("short valid ID", "abc123", true),
Entry("special characters", "6vN77lE9!K6HP2DewaN6HZ", false),
Entry("spaces", "6vN77 E9LK6HP2DewaN6HZ", false),
Entry("empty string", "", false),
)
})
Describe("ListenBrainz request payloads", func() {
It("builds valid JSON for MBID requests", func() {
mbid := "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
body := []byte(fmt.Sprintf(`[{"recording_mbid":%q}]`, mbid))
var parsed []map[string]string
Expect(json.Unmarshal(body, &parsed)).To(Succeed())
Expect(parsed[0]["recording_mbid"]).To(Equal(mbid))
})
It("builds valid JSON for metadata requests with special characters", func() {
artist := `Guns N' Roses`
title := `Sweet Child O' Mine`
album := `Appetite for Destruction`
payload := fmt.Sprintf(`[{"artist_name":%q,"track_name":%q,"release_name":%q}]`, artist, title, album)
var parsed []map[string]string
Expect(json.Unmarshal([]byte(payload), &parsed)).To(Succeed())
Expect(parsed[0]["artist_name"]).To(Equal(artist))
Expect(parsed[0]["track_name"]).To(Equal(title))
Expect(parsed[0]["release_name"]).To(Equal(album))
})
})
Describe("resolveSpotifyURL", func() {
BeforeEach(func() {
pdk.ResetMock()
host.CacheMock.ExpectedCalls = nil
host.CacheMock.Calls = nil
host.HTTPMock.ExpectedCalls = nil
host.HTTPMock.Calls = nil
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
})
It("returns cached URL on cache hit", func() {
host.CacheMock.On("GetString", spotifyURLKey).Return("https://open.spotify.com/track/cached123", true, nil)
url := resolveSpotifyURL(scrobbler.TrackInfo{
Title: "Karma Police",
Artist: "Radiohead",
Artists: []scrobbler.ArtistRef{{Name: "Radiohead"}},
Album: "OK Computer",
})
Expect(url).To(Equal("https://open.spotify.com/track/cached123"))
})
It("resolves via MBID when available", func() {
host.CacheMock.On("GetString", spotifyURLKey).Return("", false, nil)
host.CacheMock.On("SetString", spotifyURLKey, mock.Anything, mock.Anything).Return(nil)
// Mock the MBID HTTP request
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
return req.URL == "https://labs.api.listenbrainz.org/spotify-id-from-mbid/json"
})).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[{"spotify_track_ids":["63OQupATfueTdZMWIV7nzz"]}]`)}, nil)
url := resolveSpotifyURL(scrobbler.TrackInfo{
Title: "Karma Police",
Artist: "Radiohead",
Artists: []scrobbler.ArtistRef{{Name: "Radiohead"}},
Album: "OK Computer",
MBZRecordingID: "mbid-123",
})
Expect(url).To(Equal("https://open.spotify.com/track/63OQupATfueTdZMWIV7nzz"))
host.CacheMock.AssertCalled(GinkgoT(), "SetString", spotifyURLKey, "https://open.spotify.com/track/63OQupATfueTdZMWIV7nzz", spotifyCacheTTLHit)
})
It("falls back to metadata lookup when MBID fails", func() {
host.CacheMock.On("GetString", spotifyURLKey).Return("", false, nil)
host.CacheMock.On("SetString", spotifyURLKey, mock.Anything, mock.Anything).Return(nil)
// MBID request fails
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
return req.URL == "https://labs.api.listenbrainz.org/spotify-id-from-mbid/json"
})).Return(&host.HTTPResponse{StatusCode: 404, Body: []byte(`[]`)}, nil)
// Metadata request succeeds
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
return req.URL == "https://labs.api.listenbrainz.org/spotify-id-from-metadata/json"
})).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[{"spotify_track_ids":["4wlLbLeDWbA6TzwZFp1UaK"]}]`)}, nil)
url := resolveSpotifyURL(scrobbler.TrackInfo{
Title: "Karma Police",
Artist: "Radiohead",
Artists: []scrobbler.ArtistRef{{Name: "Radiohead"}},
Album: "OK Computer",
MBZRecordingID: "mbid-123",
})
Expect(url).To(Equal("https://open.spotify.com/track/4wlLbLeDWbA6TzwZFp1UaK"))
})
It("falls back to search URL when both lookups fail", func() {
host.CacheMock.On("GetString", spotifyURLKey).Return("", false, nil)
host.CacheMock.On("SetString", spotifyURLKey, mock.Anything, mock.Anything).Return(nil)
// No MBID, metadata request fails
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
return req.URL == "https://labs.api.listenbrainz.org/spotify-id-from-metadata/json"
})).Return(&host.HTTPResponse{StatusCode: 500, Body: []byte(`error`)}, nil)
url := resolveSpotifyURL(scrobbler.TrackInfo{
Title: "Karma Police",
Artist: "Radiohead",
Artists: []scrobbler.ArtistRef{{Name: "Radiohead"}},
Album: "OK Computer",
})
Expect(url).To(HavePrefix("https://open.spotify.com/search/"))
Expect(url).To(ContainSubstring("Radiohead"))
host.CacheMock.AssertCalled(GinkgoT(), "SetString", spotifyURLKey, mock.Anything, spotifyCacheTTLMiss)
})
It("uses Artists[0] for primary artist", func() {
host.CacheMock.On("GetString", spotifyURLKey).Return("", false, nil)
host.CacheMock.On("SetString", spotifyURLKey, mock.Anything, mock.Anything).Return(nil)
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
return req.URL == "https://labs.api.listenbrainz.org/spotify-id-from-metadata/json"
})).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[{"spotify_track_ids":["4tIGK5G9hNDA50ZdGioZRG"]}]`)}, nil)
url := resolveSpotifyURL(scrobbler.TrackInfo{
Title: "Some Song",
Artist: "",
Album: "Some Album",
Artists: []scrobbler.ArtistRef{{Name: "Fallback Artist"}},
})
Expect(url).To(Equal("https://open.spotify.com/track/4tIGK5G9hNDA50ZdGioZRG"))
})
})
})