Files
supersonic/backend/discordpresence.go
T
2026-07-29 04:35:25 -05:00

267 lines
6.9 KiB
Go

package backend
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"log"
"os"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/backend/player"
"github.com/hugolgst/rich-go/ipc"
)
const discordApplicationID = "1531906238919475352"
// DiscordPresence publishes playback information to the local Discord desktop
// client. All RPC failures are intentionally non-fatal: Discord may not be
// installed or running, and that should never affect playback.
type DiscordPresence struct {
pm *PlaybackManager
server func() mediaprovider.MediaProvider
mu sync.Mutex
loggedIn bool
shareURL map[string]string
artURL map[string]string
artwork *musicBrainzArtworkResolver
}
type discordRPCActivity struct {
Type int `json:"type"`
StatusDisplayType int `json:"status_display_type"`
Details string `json:"details,omitempty"`
State string `json:"state,omitempty"`
Timestamps *discordRPCTimestamps `json:"timestamps,omitempty"`
Assets *discordRPCAssets `json:"assets,omitempty"`
Buttons []discordRPCButton `json:"buttons,omitempty"`
}
type discordRPCTimestamps struct {
Start int64 `json:"start,omitempty"`
End int64 `json:"end,omitempty"`
}
type discordRPCAssets struct {
LargeImage string `json:"large_image,omitempty"`
LargeText string `json:"large_text,omitempty"`
}
type discordRPCButton struct {
Label string `json:"label"`
URL string `json:"url"`
}
func NewDiscordPresence(pm *PlaybackManager, server func() mediaprovider.MediaProvider, artwork *musicBrainzArtworkResolver) *DiscordPresence {
d := &DiscordPresence{pm: pm, server: server, shareURL: make(map[string]string), artURL: make(map[string]string), artwork: artwork}
pm.OnSongChange(func(item mediaprovider.MediaItem, _ *mediaprovider.Track) {
d.update()
d.createShareURL(item)
d.resolveArtwork(item)
})
pm.OnRadioMetadataChange(func(_, _, _ string) { d.update() })
pm.OnPlaying(d.update)
pm.OnPaused(d.update)
pm.OnSeek(d.update)
pm.OnStopped(d.clear)
return d
}
func (d *DiscordPresence) loginLocked() bool {
if d.loggedIn {
return true
}
if err := ipc.OpenSocket(); err != nil {
return false
}
payload, err := json.Marshal(map[string]string{"v": "1", "client_id": discordApplicationID})
if err != nil {
_ = ipc.CloseSocket()
return false
}
ipc.Send(0, string(payload))
d.loggedIn = true
return true
}
func (d *DiscordPresence) update() {
item := d.pm.NowPlaying()
if item == nil || d.pm.PlaybackStatus().State == player.Stopped {
d.clear()
return
}
metadata := item.Metadata()
status := d.pm.PlaybackStatus()
activity := discordActivity(metadata, status)
d.mu.Lock()
defer d.mu.Unlock()
if shareURL := d.shareURL[metadata.ID]; shareURL != "" && metadata.Type == mediaprovider.MediaItemTypeTrack {
log.Printf("Discord share URL for %q: %s", metadata.Name, shareURL)
activity.Buttons = []discordRPCButton{{Label: "View Song", URL: shareURL}}
}
if url := d.artURL[metadata.ID]; url != "" {
activity.Assets.LargeImage = url
}
if d.loginLocked() {
if err := setDiscordActivity(activity); err != nil {
log.Printf("failed to update Discord Rich Presence: %v", err)
}
}
}
func (d *DiscordPresence) resolveArtwork(item mediaprovider.MediaItem) {
if item == nil || d.artwork == nil {
return
}
metadata := item.Metadata()
if metadata.Album == "" || len(metadata.Artists) == 0 {
return
}
id := metadata.ID
d.mu.Lock()
_, exists := d.artURL[id]
d.mu.Unlock()
if exists {
return
}
go func() {
artURL := d.artwork.Resolve(metadata.Artists[0], metadata.Album)
d.mu.Lock()
d.artURL[id] = artURL
d.mu.Unlock()
if artURL != "" {
if current := d.pm.NowPlaying(); current != nil && current.Metadata().ID == id {
d.update()
}
}
}()
}
func discordActivity(metadata mediaprovider.MediaItemMetadata, status PlaybackStatus) discordRPCActivity {
artist := strings.Join(metadata.Artists, ", ")
state := artist
if metadata.Album != "" {
if state != "" {
state += " — "
}
state += metadata.Album
}
if status.State == player.Paused {
if state != "" {
state += " • Paused"
} else {
state = "Paused"
}
}
activity := discordRPCActivity{
Type: 2, // Listening
StatusDisplayType: 2, // Use Details (the song title) as the profile headline.
Details: discordText(metadata.Name),
State: discordText(state),
Assets: &discordRPCAssets{LargeText: "Listening with Supersonic"},
}
if status.State == player.Playing {
started := time.Now().Add(-time.Duration(status.TimePos * float64(time.Second)))
activity.Timestamps = &discordRPCTimestamps{Start: started.UnixMilli()}
if metadata.Duration > 0 {
activity.Timestamps.End = started.Add(metadata.Duration).UnixMilli()
}
}
return activity
}
func setDiscordActivity(activity discordRPCActivity) error {
nonceBytes := make([]byte, 16)
if _, err := rand.Read(nonceBytes); err != nil {
return err
}
payload, err := json.Marshal(struct {
Command string `json:"cmd"`
Args struct {
PID int `json:"pid"`
Activity discordRPCActivity `json:"activity"`
} `json:"args"`
Nonce string `json:"nonce"`
}{
Command: "SET_ACTIVITY",
Args: struct {
PID int `json:"pid"`
Activity discordRPCActivity `json:"activity"`
}{PID: os.Getpid(), Activity: activity},
Nonce: hex.EncodeToString(nonceBytes),
})
if err != nil {
return err
}
ipc.Send(1, string(payload))
return nil
}
// Discord limits details and state to 128 characters. It also rejects a
// non-empty field shorter than two characters.
func discordText(value string) string {
value = strings.TrimSpace(value)
if utf8.RuneCountInString(value) < 2 {
return ""
}
runes := []rune(value)
if len(runes) > 128 {
value = string(runes[:127]) + "…"
}
return value
}
func (d *DiscordPresence) createShareURL(item mediaprovider.MediaItem) {
if item == nil || item.Metadata().Type != mediaprovider.MediaItemTypeTrack {
return
}
id := item.Metadata().ID
d.mu.Lock()
_, exists := d.shareURL[id]
d.mu.Unlock()
if exists {
return
}
go func() {
server := d.server()
sharing, ok := server.(mediaprovider.SupportsSharing)
if !ok {
return
}
url, err := sharing.CreateShareURL(id)
if err != nil {
log.Printf("failed to create Discord download share for track: %v", err)
return
}
d.mu.Lock()
d.shareURL[id] = url.String()
d.mu.Unlock()
log.Printf("Created Discord share URL for %q: %s", item.Metadata().Name, url.String())
if current := d.pm.NowPlaying(); current != nil && current.Metadata().ID == id {
d.update()
}
}()
}
func (d *DiscordPresence) clear() {
d.mu.Lock()
defer d.mu.Unlock()
if d.loggedIn {
// Closing RPC clears this application's activity in Discord.
_ = ipc.CloseSocket()
d.loggedIn = false
}
}
func (d *DiscordPresence) Shutdown() {
d.clear()
}