293 lines
7.8 KiB
Go
293 lines
7.8 KiB
Go
|
|
package music
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"embed"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"log"
|
||
|
|
"net/http"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"codeberg.org/arimelody/ari-stream-tools/broadcast"
|
||
|
|
"github.com/gin-gonic/gin"
|
||
|
|
"github.com/godbus/dbus/v5"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
MPRIS_ART_URL = "mpris:artUrl"
|
||
|
|
MPRIS_LENGTH = "mpris:length"
|
||
|
|
MPRIS_TRACK_ID = "mpris:trackid"
|
||
|
|
XESAM_TITLE = "xesam:title"
|
||
|
|
XESAM_ALBUM = "xesam:album"
|
||
|
|
XESAM_ARTISTS = "xesam:artist"
|
||
|
|
|
||
|
|
DBUS_STRAWBERRY = "org.mpris.MediaPlayer2.strawberry"
|
||
|
|
)
|
||
|
|
|
||
|
|
//go:embed public
|
||
|
|
var publicFS embed.FS
|
||
|
|
//go:embed pages
|
||
|
|
var pagesFS embed.FS
|
||
|
|
|
||
|
|
type (
|
||
|
|
trackMetadata struct {
|
||
|
|
ArtworkURL string
|
||
|
|
Length int64
|
||
|
|
Title string
|
||
|
|
Album string
|
||
|
|
Artists []string
|
||
|
|
}
|
||
|
|
|
||
|
|
Service struct {
|
||
|
|
track *trackMetadata
|
||
|
|
nextTrack chan *trackMetadata
|
||
|
|
nextTrackBroadcast broadcast.BroadcastChannel[*trackMetadata]
|
||
|
|
playing bool
|
||
|
|
nextPlaying chan bool
|
||
|
|
playingBroadcast broadcast.BroadcastChannel[bool]
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
func New(ctx context.Context) *Service {
|
||
|
|
nextTrack := make(chan *trackMetadata)
|
||
|
|
nextPlaying := make(chan bool)
|
||
|
|
|
||
|
|
srv := &Service{
|
||
|
|
track: nil,
|
||
|
|
nextTrack: nextTrack,
|
||
|
|
nextTrackBroadcast: broadcast.NewBroadcastChannel(ctx, nextTrack),
|
||
|
|
playing: false,
|
||
|
|
nextPlaying: nextPlaying,
|
||
|
|
playingBroadcast: broadcast.NewBroadcastChannel(ctx, nextPlaying),
|
||
|
|
}
|
||
|
|
|
||
|
|
return srv
|
||
|
|
}
|
||
|
|
|
||
|
|
func (srv *Service) BindRoutes(group *gin.RouterGroup) {
|
||
|
|
group.GET("/public/*path", func(ctx *gin.Context) {
|
||
|
|
path := strings.TrimPrefix(ctx.Request.URL.Path, "/music/")
|
||
|
|
http.ServeFileFS(ctx.Writer, ctx.Request, publicFS, path)
|
||
|
|
})
|
||
|
|
|
||
|
|
group.GET("/sse", func(ctx *gin.Context) {
|
||
|
|
ctx.Header("connection", "keep-alive")
|
||
|
|
|
||
|
|
nextTrack := srv.nextTrackBroadcast.Subscribe()
|
||
|
|
nextPlaying := srv.playingBroadcast.Subscribe()
|
||
|
|
|
||
|
|
if srv.track == nil { <-nextTrack }
|
||
|
|
|
||
|
|
ctx.SSEvent("music", gin.H{
|
||
|
|
"title": srv.track.Title,
|
||
|
|
"artist": artistString(srv.track),
|
||
|
|
})
|
||
|
|
ctx.SSEvent("music-state", gin.H{ "playing": srv.playing })
|
||
|
|
|
||
|
|
ticker := time.NewTicker(10 * time.Millisecond)
|
||
|
|
ctx.Stream(func(w io.Writer) bool {
|
||
|
|
select {
|
||
|
|
case track := <-nextTrack:
|
||
|
|
ctx.SSEvent("music", gin.H{
|
||
|
|
"title": track.Title,
|
||
|
|
"artist": artistString(track),
|
||
|
|
})
|
||
|
|
case playing := <-nextPlaying:
|
||
|
|
ctx.SSEvent("music-state", gin.H{ "playing": playing })
|
||
|
|
case <-ticker.C:
|
||
|
|
}
|
||
|
|
return true
|
||
|
|
})
|
||
|
|
|
||
|
|
srv.nextTrackBroadcast.Cancel(nextTrack)
|
||
|
|
srv.playingBroadcast.Cancel(nextPlaying)
|
||
|
|
})
|
||
|
|
|
||
|
|
group.GET("/artwork", func(ctx *gin.Context) {
|
||
|
|
ctx.Header("cache-control", "no-cache")
|
||
|
|
|
||
|
|
if srv.track == nil || srv.track.ArtworkURL == "" {
|
||
|
|
http.ServeFileFS(ctx.Writer, ctx.Request, publicFS, "public/default-cover-art.png")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
artworkPath := strings.TrimPrefix(srv.track.ArtworkURL, "file://")
|
||
|
|
http.ServeFile(ctx.Writer, ctx.Request, artworkPath)
|
||
|
|
})
|
||
|
|
|
||
|
|
group.GET("/", func(ctx *gin.Context) {
|
||
|
|
http.ServeFileFS(ctx.Writer, ctx.Request, pagesFS, "pages/music.html")
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (srv *Service) Run(ctx context.Context) {
|
||
|
|
var err error
|
||
|
|
|
||
|
|
conn, err := dbus.ConnectSessionBus()
|
||
|
|
if err != nil {
|
||
|
|
log.Fatalf("Failed to connect to session bus: %v", err)
|
||
|
|
}
|
||
|
|
defer conn.Close()
|
||
|
|
|
||
|
|
var data map[string]dbus.Variant
|
||
|
|
obj := conn.Object(DBUS_STRAWBERRY, "/org/mpris/MediaPlayer2")
|
||
|
|
if err := obj.Call(
|
||
|
|
"org.freedesktop.DBus.Properties.GetAll",
|
||
|
|
0,
|
||
|
|
"org.mpris.MediaPlayer2.Player",
|
||
|
|
).Store(&data); err != nil {
|
||
|
|
log.Fatalf("Failed to query media player data: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
metadata, ok := data["Metadata"].Value().(map[string]dbus.Variant)
|
||
|
|
if !ok { log.Fatalf("Could not cast metadata to map[string]dbus.Variant") }
|
||
|
|
|
||
|
|
playbackStatus, ok := data["PlaybackStatus"].Value().(string)
|
||
|
|
if !ok { log.Fatalf("Could not cast metadata to string") }
|
||
|
|
|
||
|
|
// on first run, mpris:artUrl returns a temporary strawberry player artwork
|
||
|
|
// located in /tmp. this is gone by the time we attempt to read, so we need
|
||
|
|
// a way to pull the real artwork filepath at runtime.
|
||
|
|
// TODO: figure out how to pull real (not /tmp) track artwork
|
||
|
|
if srv.track, err = parseTrackMetadata(metadata); err == nil {
|
||
|
|
log.Printf("Now playing: %s - %s", artistString(srv.track), srv.track.Title)
|
||
|
|
} else {
|
||
|
|
log.Printf("Failed to parse track metadata: %v", err)
|
||
|
|
}
|
||
|
|
srv.playing = playbackStatus == "Playing"
|
||
|
|
|
||
|
|
var dbusChan chan *dbus.Signal
|
||
|
|
dbusChan, err = setupPlayerListener(conn)
|
||
|
|
if err != nil {
|
||
|
|
log.Fatalf("Failed to connect to session bus: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
failed := make(chan error)
|
||
|
|
go func() {
|
||
|
|
for {
|
||
|
|
for signal := range dbusChan {
|
||
|
|
data, ok := signal.Body[1].(map[string]dbus.Variant)
|
||
|
|
if !ok { continue }
|
||
|
|
|
||
|
|
if _, ok := data["PlaybackStatus"]; ok {
|
||
|
|
playbackStatus, ok := data["PlaybackStatus"].Value().(string)
|
||
|
|
if ok {
|
||
|
|
if playbackStatus == "Playing" {
|
||
|
|
srv.playing = true
|
||
|
|
log.Print("Music playing")
|
||
|
|
} else {
|
||
|
|
srv.playing = false
|
||
|
|
log.Print("Music paused")
|
||
|
|
}
|
||
|
|
srv.nextPlaying<-srv.playing
|
||
|
|
}
|
||
|
|
|
||
|
|
} else if _, ok := data["Metadata"]; ok {
|
||
|
|
metadata, ok := data["Metadata"].Value().(map[string]dbus.Variant)
|
||
|
|
if !ok { continue }
|
||
|
|
|
||
|
|
nextTrack, err := parseTrackMetadata(metadata)
|
||
|
|
if err != nil {
|
||
|
|
log.Printf("Failed to parse track metadata: %v", err)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
/*
|
||
|
|
if nextTrack.Title == srv.track.Title &&
|
||
|
|
slices.Compare(nextTrack.Artists, srv.track.Artists) == 0 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
*/
|
||
|
|
srv.track = nextTrack
|
||
|
|
srv.nextTrack<-nextTrack
|
||
|
|
|
||
|
|
log.Printf("Now playing: %s - %s", artistString(nextTrack), nextTrack.Title)
|
||
|
|
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
log.Printf("Session bus connection lost. Reconnecting...")
|
||
|
|
conn, err = dbus.ConnectSessionBus()
|
||
|
|
dbusChan, err = setupPlayerListener(conn)
|
||
|
|
if err != nil {
|
||
|
|
log.Fatalf("Failed to connect to session bus: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
time.Sleep(1 * time.Second)
|
||
|
|
}
|
||
|
|
}()
|
||
|
|
|
||
|
|
select {
|
||
|
|
case err := <-failed:
|
||
|
|
log.Fatalf("MPRIS/D-Bus error: %v", err)
|
||
|
|
case <-ctx.Done():
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func parseTrackMetadata(metadata map[string]dbus.Variant) (*trackMetadata, error) {
|
||
|
|
if _, exists := metadata[MPRIS_LENGTH]; !exists {
|
||
|
|
return nil, fmt.Errorf("Metadata does not contain %s", MPRIS_LENGTH)
|
||
|
|
}
|
||
|
|
length, ok := metadata[MPRIS_LENGTH].Value().(int64)
|
||
|
|
if !ok { return nil, fmt.Errorf("Failed to cast %s to int64", MPRIS_LENGTH) }
|
||
|
|
|
||
|
|
var artUrl string
|
||
|
|
if _, exists := metadata[MPRIS_ART_URL]; exists {
|
||
|
|
artUrl, ok = metadata[MPRIS_ART_URL].Value().(string)
|
||
|
|
if !ok { return nil, fmt.Errorf("Failed to cast %s to string", MPRIS_ART_URL) }
|
||
|
|
}
|
||
|
|
|
||
|
|
if _, exists := metadata[XESAM_TITLE]; !exists {
|
||
|
|
return nil, fmt.Errorf("Metadata does not contain %s", XESAM_TITLE)
|
||
|
|
}
|
||
|
|
title, ok := metadata[XESAM_TITLE].Value().(string)
|
||
|
|
if !ok { return nil, fmt.Errorf("Failed to cast %s to string", XESAM_TITLE) }
|
||
|
|
|
||
|
|
var album string
|
||
|
|
if _, exists := metadata[XESAM_ALBUM]; exists {
|
||
|
|
album, ok = metadata[XESAM_ALBUM].Value().(string)
|
||
|
|
if !ok { return nil, fmt.Errorf("Failed to cast %s to string", XESAM_ALBUM) }
|
||
|
|
}
|
||
|
|
|
||
|
|
var artists []string
|
||
|
|
if _, exists := metadata[XESAM_ARTISTS]; exists {
|
||
|
|
artists, ok = metadata[XESAM_ARTISTS].Value().([]string)
|
||
|
|
if !ok { return nil, fmt.Errorf("Failed to cast %s to []string", XESAM_ARTISTS) }
|
||
|
|
}
|
||
|
|
|
||
|
|
trackMetadata := &trackMetadata{
|
||
|
|
ArtworkURL: artUrl,
|
||
|
|
Length: length,
|
||
|
|
Title: title,
|
||
|
|
Album: album,
|
||
|
|
Artists: artists,
|
||
|
|
}
|
||
|
|
|
||
|
|
return trackMetadata, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func artistString(track *trackMetadata) string {
|
||
|
|
artists := ""
|
||
|
|
if len(track.Artists) == 0 { return "Unknown Artist" }
|
||
|
|
if len(track.Artists) == 1 { return track.Artists[0] }
|
||
|
|
artists += strings.Join(track.Artists[:len(track.Artists)-2], ", ")
|
||
|
|
artists += " & " + track.Artists[len(track.Artists) - 1]
|
||
|
|
return artists
|
||
|
|
}
|
||
|
|
|
||
|
|
func setupPlayerListener(conn *dbus.Conn) (chan *dbus.Signal, error) {
|
||
|
|
if err := conn.AddMatchSignal(
|
||
|
|
dbus.WithMatchObjectPath("/org/mpris/MediaPlayer2"),
|
||
|
|
dbus.WithMatchInterface("org.freedesktop.DBus.Properties"),
|
||
|
|
dbus.WithMatchSender(DBUS_STRAWBERRY),
|
||
|
|
); err != nil {
|
||
|
|
return nil, fmt.Errorf("Failed to configure D-Bus signal listener: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
c := make(chan *dbus.Signal)
|
||
|
|
conn.Signal(c)
|
||
|
|
|
||
|
|
return c, nil
|
||
|
|
}
|