fully-functioning music status!
- accessible via /music - only detects strawberry for now
This commit is contained in:
parent
f838edeadb
commit
8a1fabf91a
53 changed files with 923 additions and 51 deletions
292
music/music.go
Normal file
292
music/music.go
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue