Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -72,6 +73,7 @@ func parseFlags() Config {
flag.StringVar(&cfg.SettingsFile, "settings", "", "path to JSON CDJ settings config (default: <data-dir>/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)")
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
29 changes: 29 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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()
Expand Down
228 changes: 228 additions & 0 deletions mpris/mpris.go
Original file line number Diff line number Diff line change
@@ -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),
}
}
Loading
Loading