add features
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user