Files
Discodrome/main.go
T
deluan 875c29b2d1 Add optional image hosting for non-public Navidrome instances
When Navidrome is behind a private network (e.g. Tailscale), Discord
cannot fetch artwork URLs. This adds optional integration with imgbb
(24h expiry, requires API key) and uguu.se (3h expiry, no key needed)
to upload cover art to a public host.

Closes #1

# Conflicts:
#	go.mod
#	go.sum
2026-02-07 14:20:19 -05:00

205 lines
6.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"
)
// 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
// Send activity update
if err := rpc.sendActivity(clientID, input.Username, userToken, activity{
Application: clientID,
Name: "Navidrome",
Type: 2, // Listening
Details: input.Track.Title,
State: input.Track.Artist,
Timestamps: activityTimestamps{
Start: startTime,
End: endTime,
},
Assets: activityAssets{
LargeImage: getImageURL(input.Username, input.Track.ID),
LargeText: input.Track.Album,
},
}); 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() {}