validated changes

This commit is contained in:
WoahAI
2026-02-22 23:12:59 +00:00
parent 8d07bc6120
commit 89c778d62f
9 changed files with 461 additions and 37 deletions
+199 -8
View File
@@ -11,8 +11,11 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/url"
"strings"
"time"
@@ -25,11 +28,16 @@ import (
// Configuration keys
const (
clientIDKey = "clientid"
usersKey = "users"
activityNameKey = "activityname"
clientIDKey = "clientid"
usersKey = "users"
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
const (
activityNameDefault = "Default"
@@ -57,6 +65,174 @@ func init() {
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.
func getConfig() (clientID string, users map[string]string, err error) {
clientID, ok := pdk.GetConfig(clientIDKey)
@@ -157,13 +333,25 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error {
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
statusDisplayType := 2
if err := rpc.sendActivity(clientID, input.Username, userToken, activity{
Application: clientID,
Name: activityName,
Type: 2, // Listening
Details: input.Track.Title,
State: input.Track.Artist,
Application: clientID,
Name: activityName,
Type: 2, // Listening
Details: input.Track.Title,
DetailsURL: spotifySearch(input.Track.Title),
State: input.Track.Artist,
StateURL: spotifySearch(input.Track.Artist),
StatusDisplayType: &statusDisplayType,
Timestamps: activityTimestamps{
Start: startTime,
End: endTime,
@@ -171,6 +359,9 @@ func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error {
Assets: activityAssets{
LargeImage: getImageURL(input.Username, input.Track.ID),
LargeText: input.Track.Album,
LargeURL: resolveSpotifyURL(input.Track),
SmallImage: smallImage,
SmallText: smallText,
},
}); err != nil {
return fmt.Errorf("%w: failed to send activity: %v", scrobbler.ScrobblerErrorRetryLater, err)