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 }