875c29b2d1
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
205 lines
6.4 KiB
Go
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() {}
|