417 lines
14 KiB
Go
417 lines
14 KiB
Go
// Discord Rich Presence Plugin for Navidrome
|
|
//
|
|
// This plugin integrates Navidrome with Discord Rich Presence. It shows how a plugin can
|
|
// keep a real-time connection to an external service while remaining completely stateless.
|
|
//
|
|
// Capabilities: Scrobbler, SchedulerCallback, WebSocketCallback
|
|
//
|
|
// NOTE: This plugin is for demonstration purposes only. It relies on the user's Discord
|
|
// token being stored in the Navidrome configuration file, which is not secure and may be
|
|
// against Discord's terms of service. Use it at your own risk.
|
|
package main
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/plugins/pdk/go/host"
|
|
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
|
"github.com/navidrome/navidrome/plugins/pdk/go/scheduler"
|
|
"github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
|
|
"github.com/navidrome/navidrome/plugins/pdk/go/websocket"
|
|
)
|
|
|
|
// Configuration keys
|
|
const (
|
|
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"
|
|
activityNameTrack = "Track"
|
|
activityNameArtist = "Artist"
|
|
activityNameAlbum = "Album"
|
|
)
|
|
|
|
// userToken represents a user-token mapping from the config
|
|
type userToken struct {
|
|
Username string `json:"username"`
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
// discordPlugin implements the scrobbler and scheduler interfaces.
|
|
type discordPlugin struct{}
|
|
|
|
// rpc handles Discord gateway communication (via websockets).
|
|
var rpc = &discordRPC{}
|
|
|
|
// init registers the plugin capabilities
|
|
func init() {
|
|
scrobbler.Register(&discordPlugin{})
|
|
scheduler.Register(&discordPlugin{})
|
|
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)
|
|
if !ok || clientID == "" {
|
|
pdk.Log(pdk.LogWarn, "missing ClientID in configuration")
|
|
return "", nil, nil
|
|
}
|
|
|
|
// Get the users array from config
|
|
usersJSON, ok := pdk.GetConfig(usersKey)
|
|
if !ok || usersJSON == "" {
|
|
pdk.Log(pdk.LogWarn, "no users configured")
|
|
return clientID, nil, nil
|
|
}
|
|
|
|
// Parse the JSON array
|
|
var userTokens []userToken
|
|
if err := json.Unmarshal([]byte(usersJSON), &userTokens); err != nil {
|
|
pdk.Log(pdk.LogError, fmt.Sprintf("failed to parse users config: %v", err))
|
|
return clientID, nil, nil
|
|
}
|
|
|
|
if len(userTokens) == 0 {
|
|
pdk.Log(pdk.LogWarn, "no users configured")
|
|
return clientID, nil, nil
|
|
}
|
|
|
|
// Build the users map
|
|
users = make(map[string]string)
|
|
for _, ut := range userTokens {
|
|
if ut.Username != "" && ut.Token != "" {
|
|
users[ut.Username] = ut.Token
|
|
}
|
|
}
|
|
|
|
if len(users) == 0 {
|
|
pdk.Log(pdk.LogWarn, "no valid users configured")
|
|
return clientID, nil, nil
|
|
}
|
|
|
|
return clientID, users, nil
|
|
}
|
|
|
|
// ============================================================================
|
|
// Scrobbler Implementation
|
|
// ============================================================================
|
|
|
|
// IsAuthorized checks if a user is authorized for Discord Rich Presence.
|
|
func (p *discordPlugin) IsAuthorized(input scrobbler.IsAuthorizedRequest) (bool, error) {
|
|
_, users, err := getConfig()
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to check user authorization: %w", err)
|
|
}
|
|
|
|
_, authorized := users[input.Username]
|
|
pdk.Log(pdk.LogInfo, fmt.Sprintf("IsAuthorized for user %s: %v", input.Username, authorized))
|
|
return authorized, nil
|
|
}
|
|
|
|
// NowPlaying sends a now playing notification to Discord.
|
|
func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error {
|
|
pdk.Log(pdk.LogInfo, fmt.Sprintf("Setting presence for user %s, track: %s", input.Username, input.Track.Title))
|
|
|
|
// Load configuration
|
|
clientID, users, err := getConfig()
|
|
if err != nil {
|
|
return fmt.Errorf("%w: failed to get config: %v", scrobbler.ScrobblerErrorRetryLater, err)
|
|
}
|
|
|
|
// Check authorization
|
|
userToken, authorized := users[input.Username]
|
|
if !authorized {
|
|
return fmt.Errorf("%w: user '%s' not authorized", scrobbler.ScrobblerErrorNotAuthorized, input.Username)
|
|
}
|
|
|
|
// Connect to Discord
|
|
if err := rpc.connect(input.Username, userToken); err != nil {
|
|
return fmt.Errorf("%w: failed to connect to Discord: %v", scrobbler.ScrobblerErrorRetryLater, err)
|
|
}
|
|
|
|
// Cancel any existing completion schedule
|
|
_ = host.SchedulerCancelSchedule(fmt.Sprintf("%s-clear", input.Username))
|
|
|
|
// Calculate timestamps
|
|
now := time.Now().Unix()
|
|
startTime := (now - int64(input.Position)) * 1000
|
|
endTime := startTime + int64(input.Track.Duration)*1000
|
|
|
|
// Resolve the activity name based on configuration
|
|
activityName := "Navidrome"
|
|
activityNameOption, _ := pdk.GetConfig(activityNameKey)
|
|
switch activityNameOption {
|
|
case activityNameTrack:
|
|
activityName = input.Track.Title
|
|
case activityNameAlbum:
|
|
activityName = input.Track.Album
|
|
case activityNameArtist:
|
|
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,
|
|
DetailsURL: spotifySearch(input.Track.Title),
|
|
State: input.Track.Artist,
|
|
StateURL: spotifySearch(input.Track.Artist),
|
|
StatusDisplayType: &statusDisplayType,
|
|
Timestamps: activityTimestamps{
|
|
Start: startTime,
|
|
End: endTime,
|
|
},
|
|
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)
|
|
}
|
|
|
|
// Schedule a timer to clear the activity after the track completes
|
|
remainingSeconds := int32(input.Track.Duration) - input.Position + 5
|
|
_, err = host.SchedulerScheduleOneTime(remainingSeconds, payloadClearActivity, fmt.Sprintf("%s-clear", input.Username))
|
|
if err != nil {
|
|
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to schedule completion timer: %v", err))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Scrobble handles scrobble requests (no-op for Discord).
|
|
func (p *discordPlugin) Scrobble(_ scrobbler.ScrobbleRequest) error {
|
|
// Discord Rich Presence doesn't need scrobble events
|
|
return nil
|
|
}
|
|
|
|
// ============================================================================
|
|
// Scheduler Callback Implementation
|
|
// ============================================================================
|
|
|
|
// OnCallback handles scheduler callbacks.
|
|
func (p *discordPlugin) OnCallback(input scheduler.SchedulerCallbackRequest) error {
|
|
pdk.Log(pdk.LogDebug, fmt.Sprintf("Scheduler callback: id=%s, payload=%s, recurring=%v", input.ScheduleID, input.Payload, input.IsRecurring))
|
|
|
|
// Route based on payload
|
|
switch input.Payload {
|
|
case payloadHeartbeat:
|
|
// Heartbeat callback - scheduleId is the username
|
|
if err := rpc.handleHeartbeatCallback(input.ScheduleID); err != nil {
|
|
return err
|
|
}
|
|
|
|
case payloadClearActivity:
|
|
// Clear activity callback - scheduleId is "username-clear"
|
|
username := strings.TrimSuffix(input.ScheduleID, "-clear")
|
|
if err := rpc.handleClearActivityCallback(username); err != nil {
|
|
return err
|
|
}
|
|
|
|
default:
|
|
pdk.Log(pdk.LogWarn, fmt.Sprintf("Unknown scheduler callback payload: %s", input.Payload))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func main() {}
|