From d474bc67dcb7217703afa4f3f7222c9879859fd5 Mon Sep 17 00:00:00 2001 From: Vynull App Date: Tue, 28 Jul 2026 20:29:04 +0000 Subject: [PATCH] mpris: publish the audible deck on the D-Bus session bus The desktop already has now-playing surfaces everywhere - GNOME/KDE media controls and lock screens, playerctl, waybar/polybar modules, KDE Connect phone mirroring - and they all speak MPRIS. Vynull now claims org.mpris.MediaPlayer2.vynull and mirrors the audible deck (the same selection /api/nowplaying makes for the OBS overlay): title, artist, length, playhead position estimated from the beat counter, and cover art via the existing artwork endpoint URL. Vynull observes decks rather than controlling them, so the player is deliberately read-only: every capability is false and the control methods are answered as no-ops, which the spec supports. Metadata is set before PlaybackStatus so clients reacting to the status change never read stale metadata, and the metadata dict always carries the same key set: godbus's prop package merges map properties on store and never deletes keys, so a shrinking dict would leave the previous track's fields behind (found by the integration test against a live bus). On by default, --mpris=false to opt out; a missing session bus (headless) is a debug log line, never a startup failure. First new dependency in a while: godbus/dbus/v5, fetched over Tor. Tested against a private dbus-daemon per test run: name claim, identity, read-only capabilities, the stopped/playing/stopped lifecycle with full metadata assertions, no-op control calls, and the clean no-bus error path. --- README.md | 1 + api/api.go | 7 ++ config.go | 2 + go.mod | 1 + go.sum | 2 + main.go | 29 ++++++ mpris/mpris.go | 228 ++++++++++++++++++++++++++++++++++++++++++++ mpris/mpris_test.go | 148 ++++++++++++++++++++++++++++ 8 files changed, 418 insertions(+) create mode 100644 mpris/mpris.go create mode 100644 mpris/mpris_test.go diff --git a/README.md b/README.md index a379f77..c87f10c 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Use it at your own risk, and back up your rekordbox library before importing any - **USB export** — write a rekordbox-compatible USB structure (PDB + ANLZ + settings) from the library - **Live monitor** TUI showing connected CDJs, playback state, track history, and analysis status - **External-source metadata** — when a deck plays a track from its own USB/SD (not from us), the monitor and web PLAYERS view show its real title, artist, key, cover art, waveform, and cue points, read from that player's rekordbox export and ANLZ over NFS, instead of a wrong local ID match +- **MPRIS now-playing** — the audible deck published on the D-Bus session bus: GNOME/KDE media controls and lock screens, playerctl, status-bar widgets, and KDE Connect phone mirroring see what's playing (read-only; `--mpris=false` to disable) - **Now-playing overlay** for streaming — a transparent OBS browser source at `/overlay` showing the audible deck's track (artwork, title, artist, BPM/key), driven by the live link state ## Requirements diff --git a/api/api.go b/api/api.go index ccc5b4a..eabe638 100644 --- a/api/api.go +++ b/api/api.go @@ -610,6 +610,13 @@ func (s *Server) handleNowPlaying(w http.ResponseWriter, r *http.Request) { writeJSON(w, nowPlayingFrom(s.getPlayers())) } +// NowPlayingSnapshot returns the audible deck's now-playing state — the +// same projection /api/nowplaying serves — for in-process consumers (the +// MPRIS publisher). +func (s *Server) NowPlayingSnapshot() NowPlaying { + return nowPlayingFrom(s.getPlayers()) +} + // nowPlayingFrom projects the selected player into a NowPlaying. func nowPlayingFrom(players []PlayerInfo) NowPlaying { p := selectNowPlaying(players) diff --git a/config.go b/config.go index 6a4fb0e..4205303 100644 --- a/config.go +++ b/config.go @@ -42,6 +42,7 @@ type Config struct { ImportSettings string // path to a PIONEER directory containing rekordbox .DAT files to import ExportPlaylist string // with --generate, exports only this playlist (matched by name) — USB tree contains just that playlist Web bool // if true, serve an HTML UI at the API listen address + MPRIS bool // publish now-playing on the D-Bus session bus (default true; no-op without a bus) TUI bool // if true (default), show the interactive terminal monitor; false runs headless Listen string // API + web listen address (default: 127.0.0.1:9443; use 0.0.0.0:9443 to expose to LAN) LogLevel string // log verbosity: error|warn|info|debug|trace (default: info) @@ -72,6 +73,7 @@ func parseFlags() Config { flag.StringVar(&cfg.SettingsFile, "settings", "", "path to JSON CDJ settings config (default: /settings.json; created with defaults if missing; legacy settings.yaml is migrated automatically)") flag.StringVar(&cfg.ImportSettings, "import-settings", "", "import rekordbox MYSETTING/MYSETTING2/DJMMYSETTING/DEVSETTING .DAT files from this directory into the JSON config and exit (point at a /PIONEER directory on a rekordbox USB)") flag.StringVar(&cfg.ExportPlaylist, "export-playlist", "", "with --generate, export only this playlist (matched by name, case-insensitive). The resulting USB contains just the playlist's tracks and a single-playlist tree.") + flag.BoolVar(&cfg.MPRIS, "mpris", true, "publish the audible deck's now-playing as an MPRIS player on the D-Bus session bus (desktop media controls, playerctl, KDE Connect). Silently disabled when no session bus exists") flag.BoolVar(&cfg.Web, "web", false, "serve an HTML UI alongside the existing JSON API (off by default)") flag.BoolVar(&cfg.TUI, "tui", true, "show the interactive terminal monitor UI (on by default). Use --tui=false to run headless: no altscreen, logs stay on stdout — for systemd/nohup or non-TTY environments") flag.StringVar(&cfg.Listen, "listen", "127.0.0.1:9443", "API + web listen address. Use 0.0.0.0:9443 to expose on all interfaces (e.g. for access from another device on the LAN)") diff --git a/go.mod b/go.mod index 91de3e5..e6c4afe 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 + github.com/godbus/dbus/v5 v5.2.2 golang.org/x/image v0.43.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 0b73d09..dd67ba2 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4m github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= diff --git a/main.go b/main.go index bc3f33e..5dacd16 100644 --- a/main.go +++ b/main.go @@ -30,6 +30,7 @@ import ( "github.com/vynulldev/vynull/library" "github.com/vynulldev/vynull/link/prolink" "github.com/vynulldev/vynull/mediadb" + "github.com/vynulldev/vynull/mpris" "github.com/vynulldev/vynull/nfs" "github.com/vynulldev/vynull/pdb" ) @@ -606,6 +607,34 @@ func main() { if cfg.Web { log.Printf("web UI enabled: http://%s/", displayAddr(cfg.Listen)) } + // MPRIS: mirror the audible deck to the desktop's media surfaces. Missing + // session bus (headless) is normal — debug-log and move on. + if cfg.MPRIS { + base := "http://" + displayAddr(cfg.Listen) + if _, err := mpris.Start(ctx, func() mpris.NowPlaying { + np := apiSrv.NowPlayingSnapshot() + out := mpris.NowPlaying{ + Playing: np.Playing, + DeviceNumber: np.DeviceNumber, + TrackID: np.TrackID, + Title: np.Title, + Artist: np.Artist, + DurationMs: np.DurationMs, + } + if np.ArtworkURL != "" { + out.ArtURL = base + np.ArtworkURL + } + // Position from the beat counter: beats elapsed over tempo. + if np.BeatInTrack > 0 && np.BPM > 0 { + out.PositionMs = uint32(float64(np.BeatInTrack-1) / np.BPM * 60000) + } + return out + }, time.Second); err != nil { + dlog.Debugf("mpris: disabled: %v", err) + } else { + log.Printf("mpris: publishing now-playing on the session bus") + } + } srvWg.Add(1) go func() { defer srvWg.Done() diff --git a/mpris/mpris.go b/mpris/mpris.go new file mode 100644 index 0000000..69389a8 --- /dev/null +++ b/mpris/mpris.go @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package mpris publishes the audible deck's now-playing state as an MPRIS +// player on the D-Bus session bus (org.mpris.MediaPlayer2.vynull), so the +// desktop's media surfaces pick it up with no configuration: GNOME/KDE +// media controls and lock screens, playerctl, waybar/polybar modules, and +// KDE Connect's phone mirroring. +// +// Vynull is not a player — it observes decks — so the published player is +// deliberately read-only: every capability (CanPlay, CanPause, CanControl, +// ...) is false and the control methods are no-ops, which the MPRIS spec +// supports. Clients render the metadata without working transport controls. +package mpris + +import ( + "context" + "fmt" + "time" + + "github.com/godbus/dbus/v5" + "github.com/godbus/dbus/v5/introspect" + "github.com/godbus/dbus/v5/prop" +) + +const ( + busName = "org.mpris.MediaPlayer2.vynull" + objectPath = "/org/mpris/MediaPlayer2" + rootIface = "org.mpris.MediaPlayer2" + playerFace = "org.mpris.MediaPlayer2.Player" +) + +// NowPlaying is the snapshot the publisher renders into MPRIS properties. +type NowPlaying struct { + Playing bool + DeviceNumber uint8 + TrackID uint32 + Title string + Artist string + DurationMs uint32 + PositionMs uint32 // 0 = unknown + ArtURL string // absolute URL, or "" +} + +// playerMethods are the org.mpris.MediaPlayer2.Player controls. All are +// advertised as unavailable and do nothing; they exist because the +// interface requires them. Exported via a method table rather than struct +// methods so the MPRIS-mandated names (Seek, ...) don't trip go vet's +// standard-method-signature check. +var playerMethods = map[string]interface{}{ + "Next": func() *dbus.Error { return nil }, + "Previous": func() *dbus.Error { return nil }, + "Pause": func() *dbus.Error { return nil }, + "PlayPause": func() *dbus.Error { return nil }, + "Stop": func() *dbus.Error { return nil }, + "Play": func() *dbus.Error { return nil }, + "Seek": func(x int64) *dbus.Error { return nil }, + "SetPosition": func(o dbus.ObjectPath, x int64) *dbus.Error { return nil }, + "OpenUri": func(s string) *dbus.Error { return nil }, +} + +// rootMethods implement org.mpris.MediaPlayer2. Quit/Raise are advertised +// unavailable and do nothing. +var rootMethods = map[string]interface{}{ + "Raise": func() *dbus.Error { return nil }, + "Quit": func() *dbus.Error { return nil }, +} + +// Publisher owns the bus connection and the polling loop. +type Publisher struct { + conn *dbus.Conn + props *prop.Properties + last NowPlaying +} + +// Start connects to the session bus, claims the MPRIS name, and begins +// polling snapshot at the given interval, updating properties (and emitting +// PropertiesChanged) whenever the audible deck's state changes. Returns an +// error when there is no session bus (headless boxes) — the caller should +// log it at debug level and move on; MPRIS is strictly optional. +func Start(ctx context.Context, snapshot func() NowPlaying, interval time.Duration) (*Publisher, error) { + conn, err := dbus.ConnectSessionBus() + if err != nil { + return nil, fmt.Errorf("session bus: %w", err) + } + reply, err := conn.RequestName(busName, dbus.NameFlagDoNotQueue) + if err != nil || reply != dbus.RequestNameReplyPrimaryOwner { + conn.Close() + if err == nil { + err = fmt.Errorf("name %s already owned", busName) + } + return nil, err + } + + p := &Publisher{conn: conn} + + propsSpec := map[string]map[string]*prop.Prop{ + rootIface: { + "CanQuit": {Value: false, Emit: prop.EmitFalse}, + "CanRaise": {Value: false, Emit: prop.EmitFalse}, + "HasTrackList": {Value: false, Emit: prop.EmitFalse}, + "Identity": {Value: "Vynull", Emit: prop.EmitFalse}, + "SupportedUriSchemes": {Value: []string{}, Emit: prop.EmitFalse}, + "SupportedMimeTypes": {Value: []string{}, Emit: prop.EmitFalse}, + }, + playerFace: { + "PlaybackStatus": {Value: "Stopped", Emit: prop.EmitTrue}, + "Rate": {Value: 1.0, Emit: prop.EmitFalse}, + "Metadata": {Value: map[string]dbus.Variant{}, Emit: prop.EmitTrue}, + "Volume": {Value: 1.0, Emit: prop.EmitFalse}, + // Position deliberately EmitFalse: the spec says position moves + // without change signals; clients poll it. + "Position": {Value: int64(0), Emit: prop.EmitFalse}, + "MinimumRate": {Value: 1.0, Emit: prop.EmitFalse}, + "MaximumRate": {Value: 1.0, Emit: prop.EmitFalse}, + "CanGoNext": {Value: false, Emit: prop.EmitFalse}, + "CanGoPrevious": {Value: false, Emit: prop.EmitFalse}, + "CanPlay": {Value: false, Emit: prop.EmitFalse}, + "CanPause": {Value: false, Emit: prop.EmitFalse}, + "CanSeek": {Value: false, Emit: prop.EmitFalse}, + "CanControl": {Value: false, Emit: prop.EmitFalse}, + }, + } + props, err := prop.Export(conn, objectPath, propsSpec) + if err != nil { + conn.Close() + return nil, err + } + p.props = props + + if err := conn.ExportMethodTable(rootMethods, objectPath, rootIface); err != nil { + conn.Close() + return nil, err + } + if err := conn.ExportMethodTable(playerMethods, objectPath, playerFace); err != nil { + conn.Close() + return nil, err + } + // Introspection keeps busctl/d-feet/playerctl discovery working. + node := &introspect.Node{ + Name: objectPath, + Interfaces: []introspect.Interface{ + introspect.IntrospectData, + prop.IntrospectData, + {Name: rootIface}, + {Name: playerFace}, + }, + } + if err := conn.Export(introspect.NewIntrospectable(node), objectPath, + "org.freedesktop.DBus.Introspectable"); err != nil { + conn.Close() + return nil, err + } + + go p.loop(ctx, snapshot, interval) + return p, nil +} + +func (p *Publisher) loop(ctx context.Context, snapshot func() NowPlaying, interval time.Duration) { + t := time.NewTicker(interval) + defer t.Stop() + defer p.conn.Close() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + p.update(snapshot()) + } + } +} + +// update publishes np, emitting change signals only when something the +// clients render actually changed. Position updates silently every tick. +func (p *Publisher) update(np NowPlaying) { + p.props.SetMust(playerFace, "Position", int64(np.PositionMs)*1000) // µs + + changed := np.Playing != p.last.Playing || + np.TrackID != p.last.TrackID || + np.DeviceNumber != p.last.DeviceNumber || + np.Title != p.last.Title || + np.Artist != p.last.Artist + if !changed { + return + } + p.last = np + + status := "Stopped" + if np.Playing { + status = "Playing" + } + // Metadata BEFORE PlaybackStatus: clients react to the status change and + // immediately read metadata, so it must already be consistent. + p.props.SetMust(playerFace, "Metadata", metadataFrom(np)) + p.props.SetMust(playerFace, "PlaybackStatus", status) +} + +// metadataFrom renders the MPRIS metadata dict. The track id object path +// encodes deck + track so consecutive loads of the same track on different +// decks still register as a change. +// +// The dict ALWAYS carries the same key set: godbus's prop package stores +// map properties with dbus.Store into the existing map, which merges keys +// and never deletes — a smaller dict would leave the previous track's +// fields behind (verified against a live bus). Constant keys make every +// update a full overwrite. +func metadataFrom(np NowPlaying) map[string]dbus.Variant { + trackid := dbus.ObjectPath("/org/mpris/MediaPlayer2/TrackList/NoTrack") + artists := []string{} + if np.Playing { + trackid = dbus.ObjectPath(fmt.Sprintf("/dev/vynull/deck%d/track%d", np.DeviceNumber, np.TrackID)) + if np.Artist != "" { + artists = []string{np.Artist} + } + } + title, artURL, lengthUs := "", "", int64(0) + if np.Playing { + title = np.Title + artURL = np.ArtURL + lengthUs = int64(np.DurationMs) * 1000 + } + return map[string]dbus.Variant{ + "mpris:trackid": dbus.MakeVariant(trackid), + "xesam:title": dbus.MakeVariant(title), + "xesam:artist": dbus.MakeVariant(artists), + "mpris:length": dbus.MakeVariant(lengthUs), + "mpris:artUrl": dbus.MakeVariant(artURL), + } +} diff --git a/mpris/mpris_test.go b/mpris/mpris_test.go new file mode 100644 index 0000000..81142bc --- /dev/null +++ b/mpris/mpris_test.go @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package mpris + +import ( + "bufio" + "context" + "os" + "os/exec" + "testing" + "time" + + "github.com/godbus/dbus/v5" +) + +// withSessionBus runs fn with DBUS_SESSION_BUS_ADDRESS pointing at a private +// dbus-daemon, so the test never touches (or requires) the user's bus. +func withSessionBus(t *testing.T, fn func()) { + t.Helper() + if _, err := exec.LookPath("dbus-daemon"); err != nil { + t.Skip("dbus-daemon not installed") + } + cmd := exec.Command("dbus-daemon", "--session", "--nofork", "--print-address") + out, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + defer func() { cmd.Process.Kill(); cmd.Wait() }() + addr, err := bufio.NewReader(out).ReadString('\n') + if err != nil { + t.Fatalf("read bus address: %v", err) + } + old := os.Getenv("DBUS_SESSION_BUS_ADDRESS") + os.Setenv("DBUS_SESSION_BUS_ADDRESS", addr[:len(addr)-1]) + defer os.Setenv("DBUS_SESSION_BUS_ADDRESS", old) + fn() +} + +// TestPublisher drives the full surface against a private bus: name claim, +// identity, read-only capabilities, and the stopped -> playing -> stopped +// lifecycle with metadata following the snapshot. +func TestPublisher(t *testing.T) { + withSessionBus(t, func() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var current NowPlaying + _, err := Start(ctx, func() NowPlaying { return current }, 50*time.Millisecond) + if err != nil { + t.Fatal(err) + } + + client, err := dbus.ConnectSessionBus() + if err != nil { + t.Fatal(err) + } + defer client.Close() + obj := client.Object(busName, objectPath) + + get := func(iface, name string) interface{} { + v, err := obj.GetProperty(iface + "." + name) + if err != nil { + t.Fatalf("get %s.%s: %v", iface, name, err) + } + return v.Value() + } + waitStatus := func(want string) { + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if get(playerFace, "PlaybackStatus") == want { + return + } + time.Sleep(25 * time.Millisecond) + } + t.Fatalf("PlaybackStatus never became %q", want) + } + + // Identity + read-only capabilities. + if id := get(rootIface, "Identity"); id != "Vynull" { + t.Errorf("Identity = %v", id) + } + for _, cap := range []string{"CanPlay", "CanPause", "CanSeek", "CanControl", "CanGoNext"} { + if v := get(playerFace, cap); v != false { + t.Errorf("%s = %v, want false (read-only player)", cap, v) + } + } + waitStatus("Stopped") + + // Deck starts playing. + current = NowPlaying{ + Playing: true, DeviceNumber: 2, TrackID: 42, + Title: "Encounter", Artist: "Jerome Isma-Ae", + DurationMs: 374000, PositionMs: 61500, + ArtURL: "http://127.0.0.1:9443/api/artwork/42", + } + waitStatus("Playing") + md := get(playerFace, "Metadata").(map[string]dbus.Variant) + if v := md["xesam:title"].Value(); v != "Encounter" { + t.Errorf("title = %v", v) + } + if v := md["xesam:artist"].Value().([]string); len(v) != 1 || v[0] != "Jerome Isma-Ae" { + t.Errorf("artist = %v", v) + } + if v := md["mpris:length"].Value(); v != int64(374000000) { + t.Errorf("length = %v µs", v) + } + if v := md["mpris:artUrl"].Value(); v != "http://127.0.0.1:9443/api/artwork/42" { + t.Errorf("artUrl = %v", v) + } + if pos := get(playerFace, "Position").(int64); pos != 61500000 { + t.Errorf("Position = %d µs, want 61500000", pos) + } + + // Control methods exist and no-op (a read-only player must still + // answer them without error). + if call := obj.Call(playerFace+".Play", 0); call.Err != nil { + t.Errorf("Play call: %v", call.Err) + } + if call := obj.Call(playerFace+".Seek", 0, int64(1000)); call.Err != nil { + t.Errorf("Seek call: %v", call.Err) + } + + // Deck stops. + current = NowPlaying{} + waitStatus("Stopped") + md = get(playerFace, "Metadata").(map[string]dbus.Variant) + if v := md["xesam:title"].Value(); v != "" { + t.Errorf("stopped metadata still carries a title: %v", v) + } + if v := md["mpris:trackid"].Value(); v != dbus.ObjectPath("/org/mpris/MediaPlayer2/TrackList/NoTrack") { + t.Errorf("stopped trackid = %v", v) + } + }) +} + +// TestStartWithoutBus: no session bus must yield a clean error, not a hang +// or panic — headless boxes hit this on every boot. +func TestStartWithoutBus(t *testing.T) { + old := os.Getenv("DBUS_SESSION_BUS_ADDRESS") + os.Setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/nonexistent/vynull-test-bus") + defer os.Setenv("DBUS_SESSION_BUS_ADDRESS", old) + if _, err := Start(context.Background(), func() NowPlaying { return NowPlaying{} }, time.Second); err == nil { + t.Fatal("expected an error with no session bus") + } +}