Files
Atridad Lahiji 414021f471
Some checks failed
Build / Build (push) Has been cancelled
Addressing gemini's suggestion
2026-03-09 23:28:41 -06:00

266 lines
8.4 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 (
"encoding/json"
"fmt"
"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"
activityNameTemplateKey = "activitynametemplate"
spotifyLinksKey = "spotifylinks"
)
const (
navidromeWebsiteURL = "https://www.navidrome.org"
// navidromeLogoURL is the small overlay image shown in the bottom-right of the album art.
// The file is stored in the plugins' GitHub repository so Discord can fetch it as an external asset.
navidromeLogoURL = "https://raw.githubusercontent.com/navidrome/website/refs/heads/master/assets/icons/logo.webp"
)
// Activity name display options
const (
activityNameDefault = "Default"
activityNameTrack = "Track"
activityNameArtist = "Artist"
activityNameAlbum = "Album"
activityNameCustom = "Custom"
)
// 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)
}
// 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"
statusDisplayType := statusDisplayDetails
activityNameOption, _ := pdk.GetConfig(activityNameKey)
switch activityNameOption {
case activityNameTrack:
activityName = input.Track.Title
statusDisplayType = statusDisplayName
case activityNameAlbum:
activityName = input.Track.Album
statusDisplayType = statusDisplayName
case activityNameArtist:
activityName = input.Track.Artist
statusDisplayType = statusDisplayName
case activityNameCustom:
template, _ := pdk.GetConfig(activityNameTemplateKey)
if template != "" {
r := strings.NewReplacer(
"{track}", input.Track.Title,
"{artist}", input.Track.Artist,
"{album}", input.Track.Album,
)
activityName = r.Replace(template)
}
}
// Resolve Spotify URLs if enabled
var spotifyURL, artistSearchURL string
spotifyLinksOption, _ := pdk.GetConfig(spotifyLinksKey)
if spotifyLinksOption == "true" {
spotifyURL = resolveSpotifyURL(input.Track)
artistSearchURL = spotifySearchURL(input.Track.Artist)
}
// Send activity update
if err := rpc.sendActivity(clientID, input.Username, userToken, activity{
Application: clientID,
Name: activityName,
Type: 2, // Listening
Details: input.Track.Title,
DetailsURL: spotifyURL,
State: input.Track.Artist,
StateURL: artistSearchURL,
StatusDisplayType: statusDisplayType,
Timestamps: activityTimestamps{
Start: startTime,
End: endTime,
},
Assets: activityAssets{
LargeImage: getImageURL(input.Username, input.Track.ID),
LargeText: input.Track.Album,
LargeURL: spotifyURL,
SmallImage: navidromeLogoURL,
SmallText: "Navidrome",
SmallURL: navidromeWebsiteURL,
},
}); 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() {}