validated changes
This commit is contained in:
@@ -16,6 +16,9 @@ Based on the [Navicord](https://github.com/logixism/navicord) project.
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Shows currently playing track with title, artist, and album art
|
- Shows currently playing track with title, artist, and album art
|
||||||
|
- Clickable track title and artist name link to Spotify (direct track link via [ListenBrainz](https://listenbrainz.org), falls back to Spotify search)
|
||||||
|
- Clickable album art links to the Spotify track page
|
||||||
|
- Navidrome logo overlay on album art (optional, enabled by default)
|
||||||
- Customizable activity name: "Navidrome" is default, but can be configured to display track title, artist, or album
|
- Customizable activity name: "Navidrome" is default, but can be configured to display track title, artist, or album
|
||||||
- Displays playback progress with start/end timestamps
|
- Displays playback progress with start/end timestamps
|
||||||
- Automatic presence clearing when track finishes
|
- Automatic presence clearing when track finishes
|
||||||
@@ -120,6 +123,11 @@ Access the plugin configuration in Navidrome: **Settings > Plugins > Discord Ric
|
|||||||
- **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
|
||||||
- **When to disable**: Your Navidrome is publicly accessible and you've set `ND_BASEURL`
|
- **When to disable**: Your Navidrome is publicly accessible and you've set `ND_BASEURL`
|
||||||
|
|
||||||
|
#### Show Navidrome Logo Overlay
|
||||||
|
- **Default**: Enabled
|
||||||
|
- **What it does**: Displays the Navidrome logo as a small overlay in the corner of the album artwork
|
||||||
|
- **When to disable**: If you prefer a clean album art display without the logo badge
|
||||||
|
|
||||||
#### Users
|
#### Users
|
||||||
Add each Navidrome user who wants Discord Rich Presence. For each user, provide:
|
Add each Navidrome user who wants Discord Rich Presence. For each user, provide:
|
||||||
- **Username**: The Navidrome login username (case-sensitive)
|
- **Username**: The Navidrome login username (case-sensitive)
|
||||||
@@ -140,10 +148,10 @@ The plugin implements three Navidrome capabilities:
|
|||||||
### Host Services
|
### Host Services
|
||||||
|
|
||||||
| Service | Usage |
|
| Service | Usage |
|
||||||
|-----------------|---------------------------------------------------------------------|
|
|-----------------|--------------------------------------------------------------------------------|
|
||||||
| **HTTP** | Discord API calls (gateway discovery, external assets registration) |
|
| **HTTP** | Discord API calls (gateway discovery, external assets registration), ListenBrainz Spotify resolution |
|
||||||
| **WebSocket** | Persistent connection to Discord gateway |
|
| **WebSocket** | Persistent connection to Discord gateway |
|
||||||
| **Cache** | Sequence numbers, processed image URLs |
|
| **Cache** | Sequence numbers, processed image URLs, resolved Spotify URLs |
|
||||||
| **Scheduler** | Recurring heartbeats, one-time presence clearing |
|
| **Scheduler** | Recurring heartbeats, one-time presence clearing |
|
||||||
| **Artwork** | Track artwork public URL resolution |
|
| **Artwork** | Track artwork public URL resolution |
|
||||||
| **SubsonicAPI** | Fetches track artwork data for image hosting upload |
|
| **SubsonicAPI** | Fetches track artwork data for image hosting upload |
|
||||||
@@ -176,14 +184,30 @@ Discord requires images to be registered via their external assets API. The plug
|
|||||||
|
|
||||||
**For non-public Navidrome instances**: If your server isn't publicly accessible (e.g., behind a VPN or firewall), enable the "Upload to uguu.se" option. This uploads artwork to a temporary file host so Discord can display it.
|
**For non-public Navidrome instances**: If your server isn't publicly accessible (e.g., behind a VPN or firewall), enable the "Upload to uguu.se" option. This uploads artwork to a temporary file host so Discord can display it.
|
||||||
|
|
||||||
|
### Spotify Linking
|
||||||
|
|
||||||
|
The plugin enriches the Discord presence with clickable Spotify links so others can easily find what you're listening to:
|
||||||
|
|
||||||
|
- **Track title** → links to the Spotify track (or a Spotify search as fallback)
|
||||||
|
- **Artist name** → links to a Spotify search for the artist
|
||||||
|
- **Album art** → links to the Spotify track page
|
||||||
|
|
||||||
|
Track URLs are resolved via the [ListenBrainz Labs API](https://labs.api.listenbrainz.org):
|
||||||
|
1. If the track has a MusicBrainz Recording ID (MBID), that is used for an exact lookup
|
||||||
|
2. Otherwise, artist name, track title, and album are used for a metadata-based lookup
|
||||||
|
3. If neither resolves, a Spotify search URL is used as a fallback
|
||||||
|
|
||||||
|
Resolved URLs are cached (30 days for direct track links, 4 hours for search fallbacks).
|
||||||
|
|
||||||
### Files
|
### Files
|
||||||
|
|
||||||
| File | Description |
|
| File | Description |
|
||||||
|--------------------------------|------------------------------------------------------------------------|
|
|------------------------------------|--------------------------------------------------------------------------------|
|
||||||
| [main.go](main.go) | Plugin entry point, scrobbler and scheduler implementations |
|
| [main.go](main.go) | Plugin entry point, scrobbler and scheduler implementations, Spotify URL resolution |
|
||||||
| [rpc.go](rpc.go) | Discord gateway communication, WebSocket handling, activity management |
|
| [rpc.go](rpc.go) | Discord gateway communication, WebSocket handling, activity management |
|
||||||
| [coverart.go](coverart.go) | Artwork URL handling and optional uguu.se image hosting |
|
| [coverart.go](coverart.go) | Artwork URL handling and optional uguu.se image hosting |
|
||||||
| [manifest.json](manifest.json) | Plugin metadata and permission declarations |
|
| [manifest.json](manifest.json) | Plugin metadata and permission declarations |
|
||||||
|
| [navidrome.webp](navidrome.webp) | Navidrome logo used as the album art overlay badge |
|
||||||
| [Makefile](Makefile) | Build automation |
|
| [Makefile](Makefile) | Build automation |
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
//go:build integration
|
||||||
|
// +build integration
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -11,8 +11,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -28,8 +31,13 @@ const (
|
|||||||
clientIDKey = "clientid"
|
clientIDKey = "clientid"
|
||||||
usersKey = "users"
|
usersKey = "users"
|
||||||
activityNameKey = "activityname"
|
activityNameKey = "activityname"
|
||||||
|
navLogoOverlayKey = "navlogooverlay"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// navidromeLogoURL is the small overlay image shown in the bottom-right of the album art.
|
||||||
|
// The file is stored in the plugin repository so Discord can fetch it as an external asset.
|
||||||
|
const navidromeLogoURL = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/webp/navidrome.webp"
|
||||||
|
|
||||||
// Activity name display options
|
// Activity name display options
|
||||||
const (
|
const (
|
||||||
activityNameDefault = "Default"
|
activityNameDefault = "Default"
|
||||||
@@ -57,6 +65,174 @@ func init() {
|
|||||||
websocket.Register(rpc)
|
websocket.Register(rpc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildSpotifySearchURL constructs a Spotify search URL using artist and title.
|
||||||
|
// Used as the ultimate fallback when ListenBrainz resolution fails.
|
||||||
|
func buildSpotifySearchURL(title, artist string) string {
|
||||||
|
query := strings.TrimSpace(strings.Join([]string{artist, title}, " "))
|
||||||
|
if query == "" {
|
||||||
|
return "https://open.spotify.com/search/"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("https://open.spotify.com/search/%s", url.PathEscape(query))
|
||||||
|
}
|
||||||
|
|
||||||
|
// spotifySearch builds a Spotify search URL for a single search term.
|
||||||
|
func spotifySearch(term string) string {
|
||||||
|
term = strings.TrimSpace(term)
|
||||||
|
if term == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "https://open.spotify.com/search/" + url.PathEscape(term)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
spotifyCacheTTLHit int64 = 30 * 24 * 60 * 60 // 30 days for resolved track IDs
|
||||||
|
spotifyCacheTTLMiss int64 = 4 * 60 * 60 // 4 hours for misses (retry later)
|
||||||
|
)
|
||||||
|
|
||||||
|
// spotifyCacheKey returns a deterministic cache key for a track's Spotify URL.
|
||||||
|
func spotifyCacheKey(artist, title, album string) string {
|
||||||
|
h := sha256.Sum256([]byte(strings.ToLower(artist) + "\x00" + strings.ToLower(title) + "\x00" + strings.ToLower(album)))
|
||||||
|
return "spotify.url." + hex.EncodeToString(h[:8])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// trySpotifyFromMBID calls the ListenBrainz spotify-id-from-mbid endpoint.
|
||||||
|
func trySpotifyFromMBID(mbid string) string {
|
||||||
|
body := fmt.Sprintf(`[{"recording_mbid":"%s"}]`, mbid)
|
||||||
|
req := pdk.NewHTTPRequest(pdk.MethodPost, "https://labs.api.listenbrainz.org/spotify-id-from-mbid/json")
|
||||||
|
req.SetHeader("Content-Type", "application/json")
|
||||||
|
req.SetBody([]byte(body))
|
||||||
|
|
||||||
|
resp := req.Send()
|
||||||
|
status := resp.Status()
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
pdk.Log(pdk.LogInfo, fmt.Sprintf("ListenBrainz MBID lookup failed: HTTP %d, body=%s", status, string(resp.Body())))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
id := parseSpotifyID(resp.Body())
|
||||||
|
if id == "" {
|
||||||
|
pdk.Log(pdk.LogInfo, 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)
|
||||||
|
req := pdk.NewHTTPRequest(pdk.MethodPost, "https://labs.api.listenbrainz.org/spotify-id-from-metadata/json")
|
||||||
|
req.SetHeader("Content-Type", "application/json")
|
||||||
|
req.SetBody([]byte(payload))
|
||||||
|
|
||||||
|
pdk.Log(pdk.LogInfo, fmt.Sprintf("ListenBrainz metadata request: %s", payload))
|
||||||
|
|
||||||
|
resp := req.Send()
|
||||||
|
status := resp.Status()
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
pdk.Log(pdk.LogInfo, fmt.Sprintf("ListenBrainz metadata lookup failed: HTTP %d, body=%s", status, string(resp.Body())))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
pdk.Log(pdk.LogInfo, fmt.Sprintf("ListenBrainz metadata response: HTTP %d, body=%s", status, string(resp.Body())))
|
||||||
|
id := parseSpotifyID(resp.Body())
|
||||||
|
if id == "" {
|
||||||
|
pdk.Log(pdk.LogInfo, 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 id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
primary, _ := parsePrimaryArtist(track.Artist)
|
||||||
|
if primary == "" && 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.LogInfo, fmt.Sprintf("Spotify URL cache hit for %q - %q → %s", primary, track.Title, cached))
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
pdk.Log(pdk.LogInfo, 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.LogInfo, "MBID lookup did not return a Spotify ID, trying metadata…")
|
||||||
|
} else {
|
||||||
|
pdk.Log(pdk.LogInfo, "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 := buildSpotifySearchURL(track.Title, track.Artist)
|
||||||
|
_ = 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePrimaryArtist returns the primary artist (before "Feat." / "Ft." / "Featuring")
|
||||||
|
// and the optional feat suffix. For artist resolution, only the primary artist is used;
|
||||||
|
// co-artists identified by "Feat.", "Ft.", "Featuring", "&", or "/" are stripped.
|
||||||
|
func parsePrimaryArtist(artist string) (primary, featSuffix string) {
|
||||||
|
artist = strings.TrimSpace(artist)
|
||||||
|
if artist == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(artist)
|
||||||
|
for _, sep := range []string{" feat. ", " ft. ", " featuring "} {
|
||||||
|
if i := strings.Index(lower, sep); i >= 0 {
|
||||||
|
primary = strings.TrimSpace(artist[:i])
|
||||||
|
featSuffix = strings.TrimSpace(artist[i:])
|
||||||
|
return primary, featSuffix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Split on co-artist separators; take only the first artist.
|
||||||
|
for _, sep := range []string{" & ", " / "} {
|
||||||
|
if i := strings.Index(artist, sep); i >= 0 {
|
||||||
|
return strings.TrimSpace(artist[:i]), ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return artist, ""
|
||||||
|
}
|
||||||
|
|
||||||
// getConfig loads the plugin configuration.
|
// getConfig loads the plugin configuration.
|
||||||
func getConfig() (clientID string, users map[string]string, err error) {
|
func getConfig() (clientID string, users map[string]string, err error) {
|
||||||
clientID, ok := pdk.GetConfig(clientIDKey)
|
clientID, ok := pdk.GetConfig(clientIDKey)
|
||||||
@@ -157,13 +333,25 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error {
|
|||||||
activityName = input.Track.Artist
|
activityName = input.Track.Artist
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Navidrome logo overlay: shown by default; disabled only when explicitly set to "false"
|
||||||
|
navLogoOption, _ := pdk.GetConfig(navLogoOverlayKey)
|
||||||
|
smallImage, smallText := "", ""
|
||||||
|
if navLogoOption != "false" {
|
||||||
|
smallImage = navidromeLogoURL
|
||||||
|
smallText = "Navidrome"
|
||||||
|
}
|
||||||
|
|
||||||
// Send activity update
|
// Send activity update
|
||||||
|
statusDisplayType := 2
|
||||||
if err := rpc.sendActivity(clientID, input.Username, userToken, activity{
|
if err := rpc.sendActivity(clientID, input.Username, userToken, activity{
|
||||||
Application: clientID,
|
Application: clientID,
|
||||||
Name: activityName,
|
Name: activityName,
|
||||||
Type: 2, // Listening
|
Type: 2, // Listening
|
||||||
Details: input.Track.Title,
|
Details: input.Track.Title,
|
||||||
|
DetailsURL: spotifySearch(input.Track.Title),
|
||||||
State: input.Track.Artist,
|
State: input.Track.Artist,
|
||||||
|
StateURL: spotifySearch(input.Track.Artist),
|
||||||
|
StatusDisplayType: &statusDisplayType,
|
||||||
Timestamps: activityTimestamps{
|
Timestamps: activityTimestamps{
|
||||||
Start: startTime,
|
Start: startTime,
|
||||||
End: endTime,
|
End: endTime,
|
||||||
@@ -171,6 +359,9 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error {
|
|||||||
Assets: activityAssets{
|
Assets: activityAssets{
|
||||||
LargeImage: getImageURL(input.Username, input.Track.ID),
|
LargeImage: getImageURL(input.Username, input.Track.ID),
|
||||||
LargeText: input.Track.Album,
|
LargeText: input.Track.Album,
|
||||||
|
LargeURL: resolveSpotifyURL(input.Track),
|
||||||
|
SmallImage: smallImage,
|
||||||
|
SmallText: smallText,
|
||||||
},
|
},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("%w: failed to send activity: %v", scrobbler.ScrobblerErrorRetryLater, err)
|
return fmt.Errorf("%w: failed to send activity: %v", scrobbler.ScrobblerErrorRetryLater, err)
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
//go:build integration
|
||||||
|
// +build integration
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -121,6 +124,7 @@ var _ = Describe("discordPlugin", func() {
|
|||||||
pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"testuser","token":"test-token"}]`, true)
|
pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"testuser","token":"test-token"}]`, true)
|
||||||
pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false)
|
pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false)
|
||||||
pdk.PDKMock.On("GetConfig", activityNameKey).Return("", false)
|
pdk.PDKMock.On("GetConfig", activityNameKey).Return("", false)
|
||||||
|
pdk.PDKMock.On("GetConfig", navLogoOverlayKey).Return("", false)
|
||||||
|
|
||||||
// Connect mocks (isConnected check via heartbeat)
|
// Connect mocks (isConnected check via heartbeat)
|
||||||
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found"))
|
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found"))
|
||||||
@@ -178,6 +182,7 @@ var _ = Describe("discordPlugin", func() {
|
|||||||
pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"testuser","token":"test-token"}]`, true)
|
pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"testuser","token":"test-token"}]`, true)
|
||||||
pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false)
|
pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false)
|
||||||
pdk.PDKMock.On("GetConfig", activityNameKey).Return(configValue, configExists)
|
pdk.PDKMock.On("GetConfig", activityNameKey).Return(configValue, configExists)
|
||||||
|
pdk.PDKMock.On("GetConfig", navLogoOverlayKey).Return("", false)
|
||||||
|
|
||||||
// Connect mocks
|
// Connect mocks
|
||||||
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found"))
|
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found"))
|
||||||
|
|||||||
+14
-2
@@ -10,10 +10,12 @@
|
|||||||
"reason": "To process scrobbles on behalf of users"
|
"reason": "To process scrobbles on behalf of users"
|
||||||
},
|
},
|
||||||
"http": {
|
"http": {
|
||||||
"reason": "To communicate with Discord API for gateway discovery and image uploads",
|
"reason": "To communicate with Discord API, image uploads, Spotify track resolution via ListenBrainz, and fetch the Navidrome logo overlay",
|
||||||
"requiredHosts": [
|
"requiredHosts": [
|
||||||
"discord.com",
|
"discord.com",
|
||||||
"uguu.se"
|
"uguu.se",
|
||||||
|
"labs.api.listenbrainz.org",
|
||||||
|
"raw.githubusercontent.com"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"websocket": {
|
"websocket": {
|
||||||
@@ -64,6 +66,12 @@
|
|||||||
"title": "Upload artwork to uguu.se (enable if Navidrome is not publicly accessible)",
|
"title": "Upload artwork to uguu.se (enable if Navidrome is not publicly accessible)",
|
||||||
"default": false
|
"default": false
|
||||||
},
|
},
|
||||||
|
"navlogooverlay": {
|
||||||
|
"type": "boolean",
|
||||||
|
"title": "Show Navidrome logo overlay on album art",
|
||||||
|
"description": "Displays the Navidrome logo as a small overlay in the corner of the album artwork in Discord",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"title": "User Tokens",
|
"title": "User Tokens",
|
||||||
@@ -115,6 +123,10 @@
|
|||||||
"type": "Control",
|
"type": "Control",
|
||||||
"scope": "#/properties/uguuenabled"
|
"scope": "#/properties/uguuenabled"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "Control",
|
||||||
|
"scope": "#/properties/navlogooverlay"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "Control",
|
"type": "Control",
|
||||||
"scope": "#/properties/users",
|
"scope": "#/properties/users",
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
//go:build integration
|
||||||
|
// +build integration
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -63,13 +63,16 @@ func (r *discordRPC) OnClose(input websocket.OnCloseRequest) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// activity represents a Discord activity.
|
// activity represents a Discord activity sent via Gateway opcode 3.
|
||||||
type activity struct {
|
type activity struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type int `json:"type"`
|
Type int `json:"type"`
|
||||||
Details string `json:"details"`
|
Details string `json:"details"`
|
||||||
|
DetailsURL string `json:"details_url,omitempty"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
|
StateURL string `json:"state_url,omitempty"`
|
||||||
Application string `json:"application_id"`
|
Application string `json:"application_id"`
|
||||||
|
StatusDisplayType *int `json:"status_display_type,omitempty"`
|
||||||
Timestamps activityTimestamps `json:"timestamps"`
|
Timestamps activityTimestamps `json:"timestamps"`
|
||||||
Assets activityAssets `json:"assets"`
|
Assets activityAssets `json:"assets"`
|
||||||
}
|
}
|
||||||
@@ -82,6 +85,9 @@ type activityTimestamps struct {
|
|||||||
type activityAssets struct {
|
type activityAssets struct {
|
||||||
LargeImage string `json:"large_image"`
|
LargeImage string `json:"large_image"`
|
||||||
LargeText string `json:"large_text"`
|
LargeText string `json:"large_text"`
|
||||||
|
LargeURL string `json:"large_url,omitempty"`
|
||||||
|
SmallImage string `json:"small_image,omitempty"`
|
||||||
|
SmallText string `json:"small_text,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// presencePayload represents a Discord presence update.
|
// presencePayload represents a Discord presence update.
|
||||||
@@ -198,6 +204,16 @@ func (r *discordRPC) sendActivity(clientID, username, token string, data activit
|
|||||||
data.Assets.LargeImage = processedImage
|
data.Assets.LargeImage = processedImage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if data.Assets.SmallImage != "" {
|
||||||
|
processedSmall, err := r.processImage(data.Assets.SmallImage, clientID, token, false)
|
||||||
|
if err != nil {
|
||||||
|
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to process small image for user %s: %v", username, err))
|
||||||
|
data.Assets.SmallImage = ""
|
||||||
|
} else {
|
||||||
|
data.Assets.SmallImage = processedSmall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
presence := presencePayload{
|
presence := presencePayload{
|
||||||
Activities: []activity{data},
|
Activities: []activity{data},
|
||||||
Status: "dnd",
|
Status: "dnd",
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
//go:build integration
|
||||||
|
// +build integration
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParsePrimaryArtist(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
wantPrimary string
|
||||||
|
wantFeat string
|
||||||
|
}{
|
||||||
|
{"Radiohead", "Radiohead", ""},
|
||||||
|
{"Wretch 32 Feat. Badness & Ghetts", "Wretch 32", "Feat. Badness & Ghetts"},
|
||||||
|
{"Artist Ft. Guest", "Artist", "Ft. Guest"},
|
||||||
|
{"Artist Featuring Someone", "Artist", "Featuring Someone"},
|
||||||
|
{"PinkPantheress & Ice Spice", "PinkPantheress", ""},
|
||||||
|
{"Artist A / Artist B", "Artist A", ""},
|
||||||
|
{"", "", ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
gotPrimary, gotFeat := parsePrimaryArtist(tt.input)
|
||||||
|
if gotPrimary != tt.wantPrimary {
|
||||||
|
t.Errorf("parsePrimaryArtist(%q) primary = %q, want %q", tt.input, gotPrimary, tt.wantPrimary)
|
||||||
|
}
|
||||||
|
if gotFeat != tt.wantFeat {
|
||||||
|
t.Errorf("parsePrimaryArtist(%q) feat = %q, want %q", tt.input, gotFeat, tt.wantFeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSpotifySearchURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
title, artist string
|
||||||
|
wantPrefix string
|
||||||
|
wantContains string
|
||||||
|
}{
|
||||||
|
{"Never Gonna Give You Up", "Rick Astley", "https://open.spotify.com/search/", "Rick%20Astley"},
|
||||||
|
{"Karma Police", "Radiohead", "https://open.spotify.com/search/", "Radiohead"},
|
||||||
|
{"", "Solo Artist", "https://open.spotify.com/search/", "Solo%20Artist"},
|
||||||
|
{"Only Title", "", "https://open.spotify.com/search/", "Only%20Title"},
|
||||||
|
{"", "", "https://open.spotify.com/search/", ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := buildSpotifySearchURL(tt.title, tt.artist)
|
||||||
|
if !strings.HasPrefix(got, tt.wantPrefix) {
|
||||||
|
t.Errorf("buildSpotifySearchURL(%q, %q) = %q, want prefix %q", tt.title, tt.artist, got, tt.wantPrefix)
|
||||||
|
}
|
||||||
|
if tt.wantContains != "" && !strings.Contains(got, tt.wantContains) {
|
||||||
|
t.Errorf("buildSpotifySearchURL(%q, %q) = %q, want to contain %q", tt.title, tt.artist, got, tt.wantContains)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpotifyCacheKey(t *testing.T) {
|
||||||
|
key1 := spotifyCacheKey("Radiohead", "Karma Police", "OK Computer")
|
||||||
|
key2 := spotifyCacheKey("Radiohead", "Karma Police", "OK Computer")
|
||||||
|
key3 := spotifyCacheKey("Radiohead", "Karma Police", "The Bends")
|
||||||
|
|
||||||
|
if key1 != key2 {
|
||||||
|
t.Error("identical inputs should produce identical cache keys")
|
||||||
|
}
|
||||||
|
if key1 == key3 {
|
||||||
|
t.Error("different albums should produce different cache keys")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(key1, "spotify.url.") {
|
||||||
|
t.Errorf("cache key %q should start with 'spotify.url.'", key1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case-insensitive: "Radiohead" == "radiohead"
|
||||||
|
keyUpper := spotifyCacheKey("Radiohead", "Karma Police", "OK Computer")
|
||||||
|
keyLower := spotifyCacheKey("radiohead", "karma police", "ok computer")
|
||||||
|
if keyUpper != keyLower {
|
||||||
|
t.Error("cache key should be case-insensitive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSpotifyID(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid single result",
|
||||||
|
body: `[{"spotify_track_ids":["4tIGK5G9hNDA50ZdGioZRG"]}]`,
|
||||||
|
want: "4tIGK5G9hNDA50ZdGioZRG",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple IDs picks first",
|
||||||
|
body: `[{"artist_name":"Lil Baby & Drake","track_name":"Yes Indeed","spotify_track_ids":["6vN77lE9LK6HP2DewaN6HZ","4wlLbLeDWbA6TzwZFp1UaK"]}]`,
|
||||||
|
want: "6vN77lE9LK6HP2DewaN6HZ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid result with extra fields",
|
||||||
|
body: `[{"artist_name":"Radiohead","track_name":"Karma Police","spotify_track_ids":["63OQupATfueTdZMWIV7nzz"],"release_name":"OK Computer"}]`,
|
||||||
|
want: "63OQupATfueTdZMWIV7nzz",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty spotify_track_ids array",
|
||||||
|
body: `[{"spotify_track_ids":[]}]`,
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no spotify_track_ids field",
|
||||||
|
body: `[{"artist_name":"Unknown"}]`,
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty array",
|
||||||
|
body: `[]`,
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid JSON",
|
||||||
|
body: `not json`,
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "null spotify_track_ids with next result valid",
|
||||||
|
body: `[{"spotify_track_ids":[]},{"spotify_track_ids":["abc123"]}]`,
|
||||||
|
want: "abc123",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := parseSpotifyID([]byte(tt.body))
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("parseSpotifyID(%s) = %q, want %q", tt.body, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListenBrainzRequestPayloads(t *testing.T) {
|
||||||
|
// Verify MBID request body is valid JSON
|
||||||
|
mbid := "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
||||||
|
mbidBody := []byte(`[{"recording_mbid":"` + mbid + `"}]`)
|
||||||
|
var mbidParsed []map[string]string
|
||||||
|
if err := json.Unmarshal(mbidBody, &mbidParsed); err != nil {
|
||||||
|
t.Fatalf("MBID request body is not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
if mbidParsed[0]["recording_mbid"] != mbid {
|
||||||
|
t.Errorf("MBID body recording_mbid = %q, want %q", mbidParsed[0]["recording_mbid"], mbid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify metadata request body handles special characters via %q formatting
|
||||||
|
artist := `Guns N' Roses`
|
||||||
|
title := `Sweet Child O' Mine`
|
||||||
|
album := `Appetite for Destruction`
|
||||||
|
metaBody := []byte(`[{"artist_name":` + jsonQuote(artist) + `,"track_name":` + jsonQuote(title) + `,"release_name":` + jsonQuote(album) + `}]`)
|
||||||
|
var metaParsed []map[string]string
|
||||||
|
if err := json.Unmarshal(metaBody, &metaParsed); err != nil {
|
||||||
|
t.Fatalf("Metadata request body is not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
if metaParsed[0]["artist_name"] != artist {
|
||||||
|
t.Errorf("artist_name = %q, want %q", metaParsed[0]["artist_name"], artist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonQuote(s string) string {
|
||||||
|
b, _ := json.Marshal(s)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user