refactor: clean up integration test files and improve Spotify URL resolution logic
This commit is contained in:
@@ -1,6 +1,3 @@
|
|||||||
//go:build integration
|
|
||||||
// +build integration
|
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -11,11 +11,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -35,8 +32,8 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// navidromeLogoURL is the small overlay image shown in the bottom-right of the album art.
|
// 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.
|
// The file is stored in Navidrome's website repository so Discord can fetch it as an external asset.
|
||||||
const navidromeLogoURL = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/webp/navidrome.webp"
|
const navidromeLogoURL = "https://raw.githubusercontent.com/navidrome/website/refs/heads/master/assets/icons/logo.webp"
|
||||||
|
|
||||||
// Activity name display options
|
// Activity name display options
|
||||||
const (
|
const (
|
||||||
@@ -65,174 +62,6 @@ 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)
|
||||||
@@ -342,16 +171,16 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send activity update
|
// Send activity update
|
||||||
statusDisplayType := 2
|
spotifyURL := resolveSpotifyURL(input.Track)
|
||||||
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),
|
DetailsURL: spotifyURL,
|
||||||
State: input.Track.Artist,
|
State: input.Track.Artist,
|
||||||
StateURL: spotifySearch(input.Track.Artist),
|
StateURL: spotifySearch(input.Track.Artist),
|
||||||
StatusDisplayType: &statusDisplayType,
|
StatusDisplayType: 2,
|
||||||
Timestamps: activityTimestamps{
|
Timestamps: activityTimestamps{
|
||||||
Start: startTime,
|
Start: startTime,
|
||||||
End: endTime,
|
End: endTime,
|
||||||
@@ -359,7 +188,7 @@ 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),
|
LargeURL: spotifyURL,
|
||||||
SmallImage: smallImage,
|
SmallImage: smallImage,
|
||||||
SmallText: smallText,
|
SmallText: smallText,
|
||||||
},
|
},
|
||||||
|
|||||||
+11
-22
@@ -1,6 +1,3 @@
|
|||||||
//go:build integration
|
|
||||||
// +build integration
|
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -145,19 +142,15 @@ var _ = Describe("discordPlugin", func() {
|
|||||||
// Cancel existing clear schedule (may or may not exist)
|
// Cancel existing clear schedule (may or may not exist)
|
||||||
host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil)
|
host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil)
|
||||||
|
|
||||||
// Image mocks - cache miss, will make HTTP request to Discord
|
// Cache mocks (Spotify URL resolution + Discord image processing)
|
||||||
host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool {
|
host.CacheMock.On("GetString", mock.Anything).Return("", false, nil)
|
||||||
return strings.HasPrefix(key, "discord.image.")
|
|
||||||
})).Return("", false, nil)
|
|
||||||
host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||||
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil)
|
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil)
|
||||||
|
|
||||||
// Mock HTTP request for Discord external assets API
|
// Mock HTTP POST requests (ListenBrainz + Discord external assets API)
|
||||||
assetsReq := &pdk.HTTPRequest{}
|
postReq := &pdk.HTTPRequest{}
|
||||||
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.MatchedBy(func(url string) bool {
|
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(postReq)
|
||||||
return strings.Contains(url, "external-assets")
|
pdk.PDKMock.On("Send", postReq).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`[{"spotify_track_ids":[]}]`)))
|
||||||
})).Return(assetsReq)
|
|
||||||
pdk.PDKMock.On("Send", assetsReq).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{"key":"test-key"}`)))
|
|
||||||
|
|
||||||
// Schedule clear activity callback
|
// Schedule clear activity callback
|
||||||
host.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil)
|
host.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil)
|
||||||
@@ -202,17 +195,13 @@ var _ = Describe("discordPlugin", func() {
|
|||||||
host.SchedulerMock.On("ScheduleRecurring", mock.Anything, payloadHeartbeat, "testuser").Return("testuser", nil)
|
host.SchedulerMock.On("ScheduleRecurring", mock.Anything, payloadHeartbeat, "testuser").Return("testuser", nil)
|
||||||
host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil)
|
host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil)
|
||||||
|
|
||||||
// Image mocks
|
// Cache mocks (Spotify URL resolution + Discord image processing)
|
||||||
host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool {
|
host.CacheMock.On("GetString", mock.Anything).Return("", false, nil)
|
||||||
return strings.HasPrefix(key, "discord.image.")
|
|
||||||
})).Return("", false, nil)
|
|
||||||
host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||||
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil)
|
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil)
|
||||||
assetsReq := &pdk.HTTPRequest{}
|
postReq := &pdk.HTTPRequest{}
|
||||||
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.MatchedBy(func(url string) bool {
|
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(postReq)
|
||||||
return strings.Contains(url, "external-assets")
|
pdk.PDKMock.On("Send", postReq).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`[{"spotify_track_ids":[]}]`)))
|
||||||
})).Return(assetsReq)
|
|
||||||
pdk.PDKMock.On("Send", assetsReq).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{"key":"test-key"}`)))
|
|
||||||
host.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil)
|
host.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil)
|
||||||
|
|
||||||
err := plugin.NowPlaying(scrobbler.NowPlayingRequest{
|
err := plugin.NowPlaying(scrobbler.NowPlayingRequest{
|
||||||
|
|||||||
+2
-3
@@ -10,12 +10,11 @@
|
|||||||
"reason": "To process scrobbles on behalf of users"
|
"reason": "To process scrobbles on behalf of users"
|
||||||
},
|
},
|
||||||
"http": {
|
"http": {
|
||||||
"reason": "To communicate with Discord API, image uploads, Spotify track resolution via ListenBrainz, and fetch the Navidrome logo overlay",
|
"reason": "To communicate with Discord API, image uploads, and ListenBrainz for track resolution",
|
||||||
"requiredHosts": [
|
"requiredHosts": [
|
||||||
"discord.com",
|
"discord.com",
|
||||||
"uguu.se",
|
"uguu.se",
|
||||||
"labs.api.listenbrainz.org",
|
"labs.api.listenbrainz.org"
|
||||||
"raw.githubusercontent.com"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"websocket": {
|
"websocket": {
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
//go:build integration
|
|
||||||
// +build integration
|
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -22,10 +22,7 @@ const (
|
|||||||
presenceOpCode = 3 // Presence update operation code
|
presenceOpCode = 3 // Presence update operation code
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const heartbeatInterval = 41 // Heartbeat interval in seconds
|
||||||
heartbeatInterval = 41 // Heartbeat interval in seconds
|
|
||||||
defaultImage = "https://i.imgur.com/hb3XPzA.png"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Scheduler callback payloads for routing
|
// Scheduler callback payloads for routing
|
||||||
const (
|
const (
|
||||||
@@ -72,7 +69,7 @@ type activity struct {
|
|||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
StateURL string `json:"state_url,omitempty"`
|
StateURL string `json:"state_url,omitempty"`
|
||||||
Application string `json:"application_id"`
|
Application string `json:"application_id"`
|
||||||
StatusDisplayType *int `json:"status_display_type,omitempty"`
|
StatusDisplayType int `json:"status_display_type"`
|
||||||
Timestamps activityTimestamps `json:"timestamps"`
|
Timestamps activityTimestamps `json:"timestamps"`
|
||||||
Assets activityAssets `json:"assets"`
|
Assets activityAssets `json:"assets"`
|
||||||
}
|
}
|
||||||
@@ -121,7 +118,7 @@ func (r *discordRPC) processImage(imageURL, clientID, token string, isDefaultIma
|
|||||||
if isDefaultImage {
|
if isDefaultImage {
|
||||||
return "", fmt.Errorf("default image URL is empty")
|
return "", fmt.Errorf("default image URL is empty")
|
||||||
}
|
}
|
||||||
return r.processImage(defaultImage, clientID, token, true)
|
return r.processImage(navidromeLogoURL, clientID, token, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(imageURL, "mp:") {
|
if strings.HasPrefix(imageURL, "mp:") {
|
||||||
@@ -148,7 +145,7 @@ func (r *discordRPC) processImage(imageURL, clientID, token string, isDefaultIma
|
|||||||
if isDefaultImage {
|
if isDefaultImage {
|
||||||
return "", fmt.Errorf("failed to process default image: HTTP %d", resp.Status())
|
return "", fmt.Errorf("failed to process default image: HTTP %d", resp.Status())
|
||||||
}
|
}
|
||||||
return r.processImage(defaultImage, clientID, token, true)
|
return r.processImage(navidromeLogoURL, clientID, token, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
var data []map[string]string
|
var data []map[string]string
|
||||||
@@ -156,14 +153,14 @@ func (r *discordRPC) processImage(imageURL, clientID, token string, isDefaultIma
|
|||||||
if isDefaultImage {
|
if isDefaultImage {
|
||||||
return "", fmt.Errorf("failed to unmarshal default image response: %w", err)
|
return "", fmt.Errorf("failed to unmarshal default image response: %w", err)
|
||||||
}
|
}
|
||||||
return r.processImage(defaultImage, clientID, token, true)
|
return r.processImage(navidromeLogoURL, clientID, token, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
if isDefaultImage {
|
if isDefaultImage {
|
||||||
return "", fmt.Errorf("no data returned for default image")
|
return "", fmt.Errorf("no data returned for default image")
|
||||||
}
|
}
|
||||||
return r.processImage(defaultImage, clientID, token, true)
|
return r.processImage(navidromeLogoURL, clientID, token, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
image := data[0]["external_asset_path"]
|
image := data[0]["external_asset_path"]
|
||||||
@@ -171,7 +168,7 @@ func (r *discordRPC) processImage(imageURL, clientID, token string, isDefaultIma
|
|||||||
if isDefaultImage {
|
if isDefaultImage {
|
||||||
return "", fmt.Errorf("empty external_asset_path for default image")
|
return "", fmt.Errorf("empty external_asset_path for default image")
|
||||||
}
|
}
|
||||||
return r.processImage(defaultImage, clientID, token, true)
|
return r.processImage(navidromeLogoURL, clientID, token, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
processedImage := fmt.Sprintf("mp:%s", image)
|
processedImage := fmt.Sprintf("mp:%s", image)
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
//go:build integration
|
|
||||||
// +build integration
|
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
+182
@@ -0,0 +1,182 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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])
|
||||||
|
}
|
||||||
|
|
||||||
|
// trySpotifyFromMBID calls the ListenBrainz spotify-id-from-mbid endpoint.
|
||||||
|
func trySpotifyFromMBID(mbid string) string {
|
||||||
|
body := fmt.Sprintf(`[{"recording_mbid":%q}]`, 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.LogDebug, fmt.Sprintf("ListenBrainz MBID lookup failed: HTTP %d, body=%s", status, 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)
|
||||||
|
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.LogDebug, fmt.Sprintf("ListenBrainz metadata request: %s", payload))
|
||||||
|
|
||||||
|
resp := req.Send()
|
||||||
|
status := resp.Status()
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz metadata lookup failed: HTTP %d, body=%s", status, string(resp.Body())))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
pdk.Log(pdk.LogDebug, fmt.Sprintf("ListenBrainz metadata response: HTTP %d, body=%s", status, 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 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.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 := 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, ""
|
||||||
|
}
|
||||||
@@ -164,4 +164,3 @@ func jsonQuote(s string) string {
|
|||||||
b, _ := json.Marshal(s)
|
b, _ := json.Marshal(s)
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user