stream-tools/music/music.go
ari melody 8a1fabf91a
fully-functioning music status!
- accessible via /music
- only detects strawberry for now
2026-06-13 03:10:06 +01:00

292 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
}