Merge branch 'main' into main

This commit is contained in:
2026-04-02 15:34:34 +00:00
committed by GitHub
13 changed files with 410 additions and 43 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

+8 -7
View File
@@ -7,11 +7,11 @@ on:
description: "Release version (e.g., 1.2.3, without the 'v' prefix)" description: "Release version (e.g., 1.2.3, without the 'v' prefix)"
required: true required: true
type: string type: string
prerelease: beta:
description: "Mark this as a pre-release" description: "Beta number (1, 2, 3...). Leave empty for stable release"
required: false required: false
type: boolean type: string
default: false default: ""
permissions: permissions:
contents: write contents: write
@@ -33,8 +33,9 @@ jobs:
- name: Compute full version - name: Compute full version
run: | run: |
VERSION="${{ inputs.version }}" VERSION="${{ inputs.version }}"
if [[ "${{ inputs.prerelease }}" == "true" ]]; then BETA="${{ inputs.beta }}"
VERSION="${VERSION}-prerelease" if [[ -n "$BETA" && "$BETA" != "0" ]]; then
VERSION="${VERSION}-beta-${BETA}"
fi fi
echo "VERSION=${VERSION}" >> "$GITHUB_ENV" echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
@@ -83,6 +84,6 @@ jobs:
with: with:
tag_name: v${{ env.VERSION }} tag_name: v${{ env.VERSION }}
draft: true draft: true
prerelease: ${{ inputs.prerelease }} prerelease: ${{ inputs.beta != '' && inputs.beta != '0' }}
files: discord-rich-presence.ndp files: discord-rich-presence.ndp
generate_release_notes: true generate_release_notes: true
+3 -3
View File
@@ -23,7 +23,7 @@ clean:
rm -f $(WASM_FILE) $(PLUGIN_NAME).ndp rm -f $(WASM_FILE) $(PLUGIN_NAME).ndp
release: release:
@if [[ ! "${V}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$$ ]]; then echo "Usage: make release V=X.X.X [PRE=true]"; exit 1; fi @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 prerelease=$(if $(filter true,$(PRE)),true,false) gh workflow run create-release.yml -f version=$${V} -f beta=$(BETA)
@echo "Release v${V}$(if $(filter true,$(PRE)),-prerelease,) workflow triggered. Check progress: gh run list --workflow=create-release.yml" @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 .PHONY: release
+32 -11
View File
@@ -3,7 +3,7 @@
[![Build](https://github.com/navidrome/discord-rich-presence-plugin/actions/workflows/build.yml/badge.svg)](https://github.com/navidrome/discord-rich-presence-plugin/actions/workflows/build.yml) [![Build](https://github.com/navidrome/discord-rich-presence-plugin/actions/workflows/build.yml/badge.svg)](https://github.com/navidrome/discord-rich-presence-plugin/actions/workflows/build.yml)
[![Latest](https://img.shields.io/github/v/release/navidrome/discord-rich-presence-plugin)](https://github.com/navidrome/discord-rich-presence-plugin/releases/latest/download/discord-rich-presence.ndp) [![Latest](https://img.shields.io/github/v/release/navidrome/discord-rich-presence-plugin)](https://github.com/navidrome/discord-rich-presence-plugin/releases/latest/download/discord-rich-presence.ndp)
**Attention: This plugin requires Navidrome 0.60.2 or later.** **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. This plugin integrates Navidrome with Discord Rich Presence, displaying your currently playing track in your Discord status.
The goal is to demonstrate the capabilities of Navidrome's plugin system by implementing a real-time presence feature using Discord's Gateway API. The goal is to demonstrate the capabilities of Navidrome's plugin system by implementing a real-time presence feature using Discord's Gateway API.
@@ -23,9 +23,10 @@ Based on the [Navicord](https://github.com/logixism/navicord) project.
- Displays playback progress with start/end timestamps - Displays playback progress with start/end timestamps
- Automatic presence clearing when track finishes - Automatic presence clearing when track finishes
- Multi-user support with individual Discord tokens - Multi-user support with individual Discord tokens
- Optional 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 - Optional image hosting via [uguu.se](https://uguu.se) for non-public Navidrome instances
<img alt="Discord Rich Presence showing currently playing track with album art, artist, and playback progress" src="https://raw.githubusercontent.com/navidrome/discord-rich-presence-plugin/master/.github/screenshot.png"> <img alt="Discord Rich Presence showing currently playing track with album art, artist, and playback progress" src="https://raw.githubusercontent.com/navidrome/discord-rich-presence-plugin/master/.github/ss-richpresence.webp">
## Installation ## Installation
@@ -50,6 +51,7 @@ We don't provide instructions for obtaining the token as it may violate Discord'
- **Client ID**: Your Discord Application ID from Step 2 - **Client ID**: Your Discord Application ID from Step 2
- **Activity Name Display**: Choose what to show as the activity name (Default, Track, Album, Artist) - **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 - "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) - **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 - **Enable Spotify link-through**: Enable this to make track title and album art clickable links to Spotify
- **Users**: Add your Navidrome username and Discord token from Step 3 - **Users**: Add your Navidrome username and Discord token from Step 3
@@ -83,7 +85,18 @@ For album artwork to display in Discord, Discord needs to be able to access the
2. **Restart Navidrome** (required for ND_BASEURL changes) 2. **Restart Navidrome** (required for ND_BASEURL changes)
3. In plugin settings: **Disable** "Upload to uguu.se" 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.) **Use this if**: Your Navidrome is only accessible locally (home network, behind VPN, etc.)
**Setup**: **Setup**:
@@ -95,12 +108,15 @@ For album artwork to display in Discord, Discord needs to be able to access the
### Troubleshooting Album Art ### Troubleshooting Album Art
- **No album art showing**: Check Navidrome logs for errors - **No album art showing**: Check Navidrome logs for errors
- **Using public instance**: Verify ND_BASEURL is correct and Navidrome was restarted - **Using public instance**: Verify ND_BASEURL is correct and Navidrome was restarted
- **Using 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 - **Using uguu.se**: Check that the option is enabled and your server has internet access
## Configuration ## Configuration
Access the plugin configuration in Navidrome: **Settings > Plugins > Discord Rich Presence** Access the plugin configuration in Navidrome: **Settings > Plugins > Discord Rich Presence**
<img alt="Plugin configuration panel showing all available settings" src="https://raw.githubusercontent.com/navidrome/discord-rich-presence-plugin/master/.github/ss-config.webp">
### Configuration Fields ### Configuration Fields
#### Client ID #### Client ID
@@ -119,6 +135,11 @@ Access the plugin configuration in Navidrome: **Settings > Plugins > Discord Ric
- **Album**: Shows the currently playing track's album name - **Album**: Shows the currently playing track's album name
- **Artist**: Shows the currently playing track's artist 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 #### Upload to uguu.se
- **When to enable**: Your Navidrome instance is NOT publicly accessible from the internet - **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 - **What it does**: Automatically uploads album artwork to uguu.se (temporary hosting) so Discord can display it
@@ -150,7 +171,7 @@ The plugin implements three Navidrome capabilities:
| Service | Usage | | Service | Usage |
|-----------------|------------------------------------------------------------------------------------------------------| |-----------------|------------------------------------------------------------------------------------------------------|
| **HTTP** | Discord API calls (gateway discovery, external assets registration), ListenBrainz Spotify resolution | | **HTTP** | Discord API calls (gateway discovery, external assets registration), Cover Art Archive lookups, ListenBrainz Spotify resolution |
| **WebSocket** | Persistent connection to Discord gateway | | **WebSocket** | Persistent connection to Discord gateway |
| **Cache** | Sequence numbers, processed image URLs, resolved Spotify URLs | | **Cache** | Sequence numbers, processed image URLs, resolved Spotify URLs |
| **Scheduler** | Recurring heartbeats, one-time presence clearing | | **Scheduler** | Recurring heartbeats, one-time presence clearing |
@@ -177,13 +198,13 @@ Navidrome plugins are stateless - each call creates a fresh instance. This plugi
### Image Processing ### Image Processing
Discord requires images to be registered via their external assets API. The plugin: Discord requires images to be registered via their external assets API. The plugin resolves artwork URLs using a priority chain:
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
**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 ### Spotify Linking
@@ -206,7 +227,7 @@ Resolved URLs are cached (30 days for direct track links, 4 hours for search fal
|----------------------------------|-------------------------------------------------------------------------------------| |----------------------------------|-------------------------------------------------------------------------------------|
| [main.go](main.go) | Plugin entry point, scrobbler and scheduler implementations, Spotify URL resolution | | [main.go](main.go) | Plugin entry point, scrobbler and scheduler implementations, Spotify URL resolution |
| [rpc.go](rpc.go) | Discord gateway communication, WebSocket handling, activity management | | [rpc.go](rpc.go) | Discord gateway communication, WebSocket handling, activity management |
| [coverart.go](coverart.go) | Artwork URL handling and optional uguu.se image hosting | | [coverart.go](coverart.go) | Artwork URL handling, Cover Art Archive lookups, and optional uguu.se image hosting |
| [manifest.json](manifest.json) | Plugin metadata and permission declarations | | [manifest.json](manifest.json) | Plugin metadata and permission declarations |
| [Makefile](Makefile) | Build automation | | [Makefile](Makefile) | Build automation |
+100 -7
View File
@@ -7,10 +7,94 @@ import (
"github.com/navidrome/navidrome/plugins/pdk/go/host" "github.com/navidrome/navidrome/plugins/pdk/go/host"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk" "github.com/navidrome/navidrome/plugins/pdk/go/pdk"
"github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
) )
// Configuration key for uguu.se image hosting // Cache TTLs for cover art lookups
const uguuEnabledKey = "uguuenabled" 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 // uguu.se API response
type uguuResponse struct { type uguuResponse struct {
@@ -20,13 +104,22 @@ type uguuResponse struct {
} `json:"files"` } `json:"files"`
} }
// getImageURL retrieves the track artwork URL, optionally uploading to uguu.se. // getImageURL retrieves the track artwork URL, checking CAA first if enabled,
func getImageURL(username, trackID string) string { // 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) uguuEnabled, _ := pdk.GetConfig(uguuEnabledKey)
if uguuEnabled == "true" { 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). // getImageDirect returns the artwork URL directly from Navidrome (current behavior).
@@ -68,7 +161,7 @@ func getImageViaUguu(username, trackID string) string {
return "" return ""
} }
_ = host.CacheSetString(cacheKey, url, 9000) _ = host.CacheSetString(cacheKey, url, uguuCacheTTL)
return url return url
} }
+246 -9
View File
@@ -5,12 +5,70 @@ import (
"github.com/navidrome/navidrome/plugins/pdk/go/host" "github.com/navidrome/navidrome/plugins/pdk/go/host"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk" "github.com/navidrome/navidrome/plugins/pdk/go/pdk"
"github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "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() { var _ = Describe("getImageURL", func() {
BeforeEach(func() { BeforeEach(func() {
pdk.ResetMock() pdk.ResetMock()
@@ -27,40 +85,42 @@ var _ = Describe("getImageURL", func() {
Describe("uguu disabled (default)", func() { Describe("uguu disabled (default)", func() {
BeforeEach(func() { BeforeEach(func() {
pdk.PDKMock.On("GetConfig", caaEnabledKey).Return("", false)
pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false) pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false)
}) })
It("returns artwork URL directly", func() { It("returns artwork URL directly", func() {
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil) host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil)
url := getImageURL("testuser", "track1") url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1"})
Expect(url).To(Equal("https://example.com/art.jpg")) Expect(url).To(Equal("https://example.com/art.jpg"))
}) })
It("returns empty for localhost URL", func() { It("returns empty for localhost URL", func() {
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("http://localhost:4533/art.jpg", nil) 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()) Expect(url).To(BeEmpty())
}) })
It("returns empty when artwork fetch fails", func() { It("returns empty when artwork fetch fails", func() {
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("", errors.New("not found")) 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()) Expect(url).To(BeEmpty())
}) })
}) })
Describe("uguu enabled", func() { Describe("uguu enabled", func() {
BeforeEach(func() { BeforeEach(func() {
pdk.PDKMock.On("GetConfig", caaEnabledKey).Return("", false)
pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("true", true) pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("true", true)
}) })
It("returns cached URL when available", func() { It("returns cached URL when available", func() {
host.CacheMock.On("GetString", "uguu.artwork.track1").Return("https://a.uguu.se/cached.jpg", true, nil) 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")) Expect(url).To(Equal("https://a.uguu.se/cached.jpg"))
}) })
@@ -78,11 +138,11 @@ var _ = Describe("getImageURL", func() {
})).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{"success":true,"files":[{"url":"https://a.uguu.se/uploaded.jpg"}]}`)}, nil) })).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{"success":true,"files":[{"url":"https://a.uguu.se/uploaded.jpg"}]}`)}, nil)
// Mock cache set // Mock cache set
host.CacheMock.On("SetString", "uguu.artwork.track1", "https://a.uguu.se/uploaded.jpg", int64(9000)).Return(nil) host.CacheMock.On("SetString", "uguu.artwork.track1", "https://a.uguu.se/uploaded.jpg", uguuCacheTTL).Return(nil)
url := getImageURL("testuser", "track1") url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1"})
Expect(url).To(Equal("https://a.uguu.se/uploaded.jpg")) 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() { It("returns empty when artwork data fetch fails", func() {
@@ -90,7 +150,7 @@ var _ = Describe("getImageURL", func() {
host.SubsonicAPIMock.On("CallRaw", "/getCoverArt?u=testuser&id=track1&size=300"). host.SubsonicAPIMock.On("CallRaw", "/getCoverArt?u=testuser&id=track1&size=300").
Return("", []byte(nil), errors.New("fetch failed")) Return("", []byte(nil), errors.New("fetch failed"))
url := getImageURL("testuser", "track1") url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1"})
Expect(url).To(BeEmpty()) Expect(url).To(BeEmpty())
}) })
@@ -103,8 +163,185 @@ var _ = Describe("getImageURL", func() {
return req.URL == "https://uguu.se/upload" return req.URL == "https://uguu.se/upload"
})).Return(&host.HTTPResponse{StatusCode: 500, Body: []byte(`{"success":false}`)}, nil) })).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()) Expect(url).To(BeEmpty())
}) })
}) })
Describe("CAA enabled", func() {
BeforeEach(func() {
pdk.PDKMock.ExpectedCalls = nil
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
pdk.PDKMock.On("GetConfig", caaEnabledKey).Return("true", true)
pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false)
})
It("returns CAA URL when release HEAD succeeds", func() {
host.CacheMock.On("GetString", "caa.artwork.album-id").Return("", false, nil)
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
return req.URL == "https://coverartarchive.org/release/album-id/front-500"
})).Return(&host.HTTPResponse{
StatusCode: 307,
Headers: map[string]string{"Location": "https://archive.org/art.jpg"},
}, nil)
host.CacheMock.On("SetString", "caa.artwork.album-id", "https://archive.org/art.jpg", int64(86400)).Return(nil)
url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1", MBZAlbumID: "album-id", MBZReleaseGroupID: "rg-id"})
Expect(url).To(Equal("https://archive.org/art.jpg"))
host.ArtworkMock.AssertNotCalled(GinkgoT(), "GetTrackUrl", mock.Anything, mock.Anything)
host.SubsonicAPIMock.AssertNotCalled(GinkgoT(), "CallRaw", mock.Anything)
})
It("falls through to direct when CAA misses and uguu is disabled", func() {
host.CacheMock.On("GetString", "caa.artwork.album-id").Return("", false, nil)
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
return req.URL == "https://coverartarchive.org/release/album-id/front-500"
})).Return(&host.HTTPResponse{StatusCode: 404}, nil)
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
return req.URL == "https://coverartarchive.org/release-group/rg-id/front-500"
})).Return(&host.HTTPResponse{StatusCode: 404}, nil)
host.CacheMock.On("SetString", "caa.artwork.album-id", "", int64(14400)).Return(nil)
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil)
url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1", MBZAlbumID: "album-id", MBZReleaseGroupID: "rg-id"})
Expect(url).To(Equal("https://example.com/art.jpg"))
})
It("falls through to uguu when CAA misses and uguu is enabled", func() {
pdk.PDKMock.ExpectedCalls = nil
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
pdk.PDKMock.On("GetConfig", caaEnabledKey).Return("true", true)
pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("true", true)
host.CacheMock.On("GetString", "caa.artwork.rg.rg-id").Return("", false, nil)
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
return req.URL == "https://coverartarchive.org/release-group/rg-id/front-500"
})).Return(&host.HTTPResponse{StatusCode: 404}, nil)
host.CacheMock.On("SetString", "caa.artwork.rg.rg-id", "", int64(14400)).Return(nil)
host.CacheMock.On("GetString", "uguu.artwork.track1").Return("https://a.uguu.se/cached.jpg", true, nil)
url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1", MBZReleaseGroupID: "rg-id"})
Expect(url).To(Equal("https://a.uguu.se/cached.jpg"))
})
It("skips CAA when no MBZ IDs are present", func() {
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil)
url := getImageURL("testuser", scrobbler.TrackInfo{ID: "track1"})
Expect(url).To(Equal("https://example.com/art.jpg"))
host.HTTPMock.AssertNotCalled(GinkgoT(), "Send", mock.Anything)
})
})
})
var _ = Describe("getImageViaCoverArt", func() {
BeforeEach(func() {
pdk.ResetMock()
host.CacheMock.ExpectedCalls = nil
host.CacheMock.Calls = nil
host.HTTPMock.ExpectedCalls = nil
host.HTTPMock.Calls = nil
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
})
It("returns cached URL on cache hit", func() {
host.CacheMock.On("GetString", "caa.artwork.album-123").Return("https://archive.org/cached.jpg", true, nil)
result := getImageViaCoverArt("album-123", "rg-456")
Expect(result).To(Equal("https://archive.org/cached.jpg"))
host.HTTPMock.AssertNotCalled(GinkgoT(), "Send", mock.Anything)
})
It("returns empty on cache hit with empty string (known miss)", func() {
host.CacheMock.On("GetString", "caa.artwork.album-123").Return("", true, nil)
result := getImageViaCoverArt("album-123", "rg-456")
Expect(result).To(BeEmpty())
host.HTTPMock.AssertNotCalled(GinkgoT(), "Send", mock.Anything)
})
It("returns release URL on 307 and caches it", func() {
host.CacheMock.On("GetString", "caa.artwork.album-123").Return("", false, nil)
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
return req.URL == "https://coverartarchive.org/release/album-123/front-500"
})).Return(&host.HTTPResponse{
StatusCode: 307,
Headers: map[string]string{"Location": "https://archive.org/release-art.jpg"},
}, nil)
host.CacheMock.On("SetString", "caa.artwork.album-123", "https://archive.org/release-art.jpg", int64(86400)).Return(nil)
result := getImageViaCoverArt("album-123", "rg-456")
Expect(result).To(Equal("https://archive.org/release-art.jpg"))
host.CacheMock.AssertCalled(GinkgoT(), "SetString", "caa.artwork.album-123", "https://archive.org/release-art.jpg", int64(86400))
})
It("falls back to release-group when release returns 404", func() {
host.CacheMock.On("GetString", "caa.artwork.album-123").Return("", false, nil)
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
return req.URL == "https://coverartarchive.org/release/album-123/front-500"
})).Return(&host.HTTPResponse{StatusCode: 404}, nil)
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
return req.URL == "https://coverartarchive.org/release-group/rg-456/front-500"
})).Return(&host.HTTPResponse{
StatusCode: 307,
Headers: map[string]string{"Location": "https://archive.org/rg-art.jpg"},
}, nil)
host.CacheMock.On("SetString", "caa.artwork.album-123", "https://archive.org/rg-art.jpg", int64(86400)).Return(nil)
result := getImageViaCoverArt("album-123", "rg-456")
Expect(result).To(Equal("https://archive.org/rg-art.jpg"))
})
It("caches empty string when both release and release-group fail", func() {
host.CacheMock.On("GetString", "caa.artwork.album-123").Return("", false, nil)
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
return req.URL == "https://coverartarchive.org/release/album-123/front-500"
})).Return(&host.HTTPResponse{StatusCode: 404}, nil)
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
return req.URL == "https://coverartarchive.org/release-group/rg-456/front-500"
})).Return(&host.HTTPResponse{StatusCode: 404}, nil)
host.CacheMock.On("SetString", "caa.artwork.album-123", "", int64(14400)).Return(nil)
result := getImageViaCoverArt("album-123", "rg-456")
Expect(result).To(BeEmpty())
host.CacheMock.AssertCalled(GinkgoT(), "SetString", "caa.artwork.album-123", "", int64(14400))
})
It("does not cache miss on transient failure", func() {
host.CacheMock.On("GetString", "caa.artwork.album-123").Return("", false, nil)
// Both requests fail with network errors (transient)
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
return req.URL == "https://coverartarchive.org/release/album-123/front-500"
})).Return((*host.HTTPResponse)(nil), errors.New("connection refused"))
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
return req.URL == "https://coverartarchive.org/release-group/rg-456/front-500"
})).Return((*host.HTTPResponse)(nil), errors.New("timeout"))
result := getImageViaCoverArt("album-123", "rg-456")
Expect(result).To(BeEmpty())
// Should NOT cache the miss since failures were transient
host.CacheMock.AssertNotCalled(GinkgoT(), "SetString", mock.Anything, mock.Anything, mock.Anything)
})
It("tries only release-group when MBZAlbumID is empty", func() {
host.CacheMock.On("GetString", "caa.artwork.rg.rg-456").Return("", false, nil)
host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool {
return req.URL == "https://coverartarchive.org/release-group/rg-456/front-500"
})).Return(&host.HTTPResponse{
StatusCode: 307,
Headers: map[string]string{"Location": "https://archive.org/rg-art.jpg"},
}, nil)
host.CacheMock.On("SetString", "caa.artwork.rg.rg-456", "https://archive.org/rg-art.jpg", int64(86400)).Return(nil)
result := getImageViaCoverArt("", "rg-456")
Expect(result).To(Equal("https://archive.org/rg-art.jpg"))
})
It("returns empty when both IDs are empty", func() {
result := getImageViaCoverArt("", "")
Expect(result).To(BeEmpty())
host.HTTPMock.AssertNotCalled(GinkgoT(), "Send", mock.Anything)
host.CacheMock.AssertNotCalled(GinkgoT(), "GetString", mock.Anything)
})
}) })
+1 -1
View File
@@ -3,7 +3,7 @@ module discord-rich-presence
go 1.25.0 go 1.25.0
require ( require (
github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260303204839-f03ca44a8ec4 github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260320221607-03844a9a369a
github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/ginkgo/v2 v2.28.1
github.com/onsi/gomega v1.39.1 github.com/onsi/gomega v1.39.1
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
+2 -2
View File
@@ -33,8 +33,8 @@ github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260303204839-f03ca44a8ec4 h1:LgSTogYiu31eQF8BMh3fDuIcZ82chzIZDi/U/HZYYbA= github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260320221607-03844a9a369a h1:EHllNfhSpL6F3EqM4M0GDHQZb7DyClw0y7afddd8XPg=
github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260303204839-f03ca44a8ec4/go.mod h1:5aedoevIXlwUFuR7kbd/WkjaiLg87D3XUFRGIwDBroo= github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-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 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
+3 -1
View File
@@ -30,6 +30,8 @@ const (
activityNameKey = "activityname" activityNameKey = "activityname"
activityNameTemplateKey = "activitynametemplate" activityNameTemplateKey = "activitynametemplate"
spotifyLinksKey = "spotifylinks" spotifyLinksKey = "spotifylinks"
caaEnabledKey = "caaenabled"
uguuEnabledKey = "uguuenabled"
) )
const ( const (
@@ -205,7 +207,7 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error {
End: endTime, End: endTime,
}, },
Assets: activityAssets{ Assets: activityAssets{
LargeImage: getImageURL(input.Username, input.Track.ID), LargeImage: getImageURL(input.Username, input.Track),
LargeText: input.Track.Album, LargeText: input.Track.Album,
LargeURL: spotifyURL, LargeURL: spotifyURL,
SmallImage: navidromeLogoURL, SmallImage: navidromeLogoURL,
+2
View File
@@ -122,6 +122,7 @@ var _ = Describe("discordPlugin", func() {
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true) pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true)
pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"testuser","token":"test-token"}]`, true) pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"testuser","token":"test-token"}]`, true)
pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false) pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false)
pdk.PDKMock.On("GetConfig", caaEnabledKey).Return("", false)
pdk.PDKMock.On("GetConfig", activityNameKey).Return("", false) pdk.PDKMock.On("GetConfig", activityNameKey).Return("", false)
pdk.PDKMock.On("GetConfig", spotifyLinksKey).Return("", false) pdk.PDKMock.On("GetConfig", spotifyLinksKey).Return("", false)
@@ -174,6 +175,7 @@ var _ = Describe("discordPlugin", func() {
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true) pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true)
pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"testuser","token":"test-token"}]`, true) pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"testuser","token":"test-token"}]`, true)
pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false) pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false)
pdk.PDKMock.On("GetConfig", caaEnabledKey).Return("", false)
pdk.PDKMock.On("GetConfig", activityNameKey).Return(configValue, configExists) pdk.PDKMock.On("GetConfig", activityNameKey).Return(configValue, configExists)
pdk.PDKMock.On("GetConfig", spotifyLinksKey).Return("", false) pdk.PDKMock.On("GetConfig", spotifyLinksKey).Return("", false)
+13 -2
View File
@@ -2,7 +2,7 @@
"$schema": "https://raw.githubusercontent.com/navidrome/navidrome/refs/heads/master/plugins/manifest-schema.json", "$schema": "https://raw.githubusercontent.com/navidrome/navidrome/refs/heads/master/plugins/manifest-schema.json",
"name": "Discord Rich Presence", "name": "Discord Rich Presence",
"author": "Navidrome Team", "author": "Navidrome Team",
"version": "1.0.0-prerelease", "version": "1.0.0",
"description": "Discord Rich Presence integration for Navidrome", "description": "Discord Rich Presence integration for Navidrome",
"website": "https://github.com/navidrome/discord-rich-presence-plugin", "website": "https://github.com/navidrome/discord-rich-presence-plugin",
"permissions": { "permissions": {
@@ -14,7 +14,8 @@
"requiredHosts": [ "requiredHosts": [
"discord.com", "discord.com",
"uguu.se", "uguu.se",
"labs.api.listenbrainz.org" "labs.api.listenbrainz.org",
"coverartarchive.org"
] ]
}, },
"websocket": { "websocket": {
@@ -67,6 +68,12 @@
"description": "Template for the activity name. Available placeholders: {track}, {artist}, {album}", "description": "Template for the activity name. Available placeholders: {track}, {artist}, {album}",
"default": "{artist} - {track}" "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": { "uguuenabled": {
"type": "boolean", "type": "boolean",
"title": "Upload artwork to uguu.se (enable if Navidrome is not publicly accessible)", "title": "Upload artwork to uguu.se (enable if Navidrome is not publicly accessible)",
@@ -138,6 +145,10 @@
} }
} }
}, },
{
"type": "Control",
"scope": "#/properties/caaenabled"
},
{ {
"type": "Control", "type": "Control",
"scope": "#/properties/uguuenabled" "scope": "#/properties/uguuenabled"