From 574a7f7a1b5e1313347211cf3332ba246f43330e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wiktor=20Zi=C4=99bka?= Date: Mon, 12 Oct 2026 14:13:05 +0200 Subject: [PATCH 1/3] refactor(mpris): replace go-mpris-server with a server on godbus github.com/quarckster/go-mpris-server is GPL-3.0. A binary that links it is under GPL-3.0, so the release binaries could not have a permissive license. internal/mpris now has a server of its own. It is written from the MPRIS 2.2 specification and uses only godbus/dbus. The apps call PlaybackChanged, StatusChanged, MetadataChanged, VolumeChanged, and Seeked on it. Changes that an MPRIS client sees: - A Set of Volume with a value of a wrong type returns an error. With the old module it stopped the program. - The metadata contains only the fields that have a value. - The introspection data shows only the interfaces that exist. - PropertiesChanged does not contain Position or CanControl. server_linux_test.go starts the server on a private session bus and checks it as a client does. The same test passed with the old module before the change. With PINE_REQUIRE_DBUS=1 it fails if dbus-daemon is absent. CI and scripts/release.sh set that variable. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 3 + AGENTS.md | 7 +- go.mod | 1 - go.sum | 2 - internal/app/model.go | 27 +- internal/mpris/adapter.go | 105 ++++--- internal/mpris/bridge.go | 20 +- internal/mpris/bridge_test.go | 5 +- internal/mpris/mpris_test.go | 28 +- internal/mpris/server.go | 357 +++++++++++++++++++++-- internal/mpris/server_linux_test.go | 425 ++++++++++++++++++++++++++++ internal/spruce/app/playback.go | 53 ++-- scripts/release.sh | 2 + 13 files changed, 898 insertions(+), 137 deletions(-) create mode 100644 internal/mpris/server_linux_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84393df..02b9098 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,9 @@ jobs: # The player tests fail if libmpv is absent. Without this variable they # skip. PINE_REQUIRE_LIBMPV: "1" + # The MPRIS server test fails on Linux if dbus-daemon is absent. Without + # this variable it skips. + PINE_REQUIRE_DBUS: "1" steps: - uses: actions/checkout@v7 diff --git a/AGENTS.md b/AGENTS.md index d3a1260..a28359c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ go vet ./... # type-check 1. Use `gofmt -l .`. Make sure that it shows no file. Use `gofmt -w ` to format a file. 2. Use `go vet ./...`. 3. Use `golangci-lint run`. Make sure that it reports 0 issues. Use version 2.14.0, which is the version in `.github/workflows/ci.yml`. -4. Use `PINE_REQUIRE_LIBMPV=1 go test ./... -count=1`. +4. Use `PINE_REQUIRE_LIBMPV=1 PINE_REQUIRE_DBUS=1 go test ./... -count=1`. ## Repository Layout @@ -104,7 +104,9 @@ The Architecture Map below describes pine and the shared packages. | `adapter.go` | D-Bus MPRIS adapter exposing playback status, metadata, and seek controls. | | `bridge.go` | Bubbletea message bridge between the MPRIS adapter and root model. | | `accessor.go` | Read-only MPRIS property accessor. | -| `server.go` | D-Bus service registration. | +| `server.go` | MPRIS server on `godbus/dbus`: the root and player interfaces, the D-Bus properties interface, introspection, and the change signals. | + +The server is written from the MPRIS 2.2 specification. The Go MPRIS server library that exists is GPL-3.0, and a GPL module makes the release binaries GPL. Do not add a module with a copyleft license. ### API (internal/abs/) @@ -150,6 +152,7 @@ Styles from theme config. Format helpers (timestamps, durations). Components: He - `internal/app/model_test.go`, `playback_test.go`, `bookmarks_test.go`, `render_test.go` for app-level tests - `internal/cache/*_test.go` for cache store and helper behavior - `internal/mpris/mpris_test.go` for the D-Bus adapter and bridge +- `internal/mpris/server_linux_test.go` starts the MPRIS server on a private session bus and checks it as an MPRIS client. It skips if `dbus-daemon` is absent. With `PINE_REQUIRE_DBUS=1` it fails instead. CI and `scripts/release.sh` set that variable. - `internal/screens/metadataedit/model_test.go` for the metadata editor - Screen models have individual `*_test.go` files - `internal/player/libmpv_test.go`, `mpv_test.go`, and `group_test.go` play audio through the real libmpv with `ao=null`. They skip if libmpv is absent. With `PINE_REQUIRE_LIBMPV=1` they fail instead. `scripts/release.sh` sets that variable. diff --git a/go.mod b/go.mod index 41eea18..9dabfc6 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,6 @@ require ( github.com/ebitengine/purego v0.11.1 github.com/godbus/dbus/v5 v5.2.2 github.com/muesli/termenv v0.16.0 - github.com/quarckster/go-mpris-server v1.0.3 github.com/sahilm/fuzzy v0.1.1 golang.org/x/sys v0.42.0 golang.org/x/text v0.3.8 diff --git a/go.sum b/go.sum index b858437..e5d8571 100644 --- a/go.sum +++ b/go.sum @@ -58,8 +58,6 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/quarckster/go-mpris-server v1.0.3 h1:ef6d3DpxlORtdEBHnhQ/j3gS0Z3+YUfXeJhC9L9DZvA= -github.com/quarckster/go-mpris-server v1.0.3/go.mod h1:2b4IdrpnEoEfU+6fQKjYhAgdvsiz4JxmTpDAUrMJVO4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= diff --git a/internal/app/model.go b/internal/app/model.go index 42cf759..13c32ba 100644 --- a/internal/app/model.go +++ b/internal/app/model.go @@ -27,7 +27,6 @@ import ( "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/quarckster/go-mpris-server/pkg/types" ) const headerHeight = 2 @@ -183,9 +182,9 @@ func (m *Model) mprisPlaybackCmd() tea.Cmd { if m.mprisBridge == nil { return nil } - handler := m.mprisBridge.EventHandler() + server := m.mprisBridge.Server() return func() tea.Msg { - _ = handler.Player.OnPlayback() + server.PlaybackChanged() return nil } } @@ -194,9 +193,9 @@ func (m *Model) mprisPlayPauseCmd() tea.Cmd { if m.mprisBridge == nil { return nil } - handler := m.mprisBridge.EventHandler() + server := m.mprisBridge.Server() return func() tea.Msg { - _ = handler.Player.OnPlayPause() + server.StatusChanged() return nil } } @@ -205,9 +204,9 @@ func (m *Model) mprisEndedCmd() tea.Cmd { if m.mprisBridge == nil { return nil } - handler := m.mprisBridge.EventHandler() + server := m.mprisBridge.Server() return func() tea.Msg { - _ = handler.Player.OnEnded() + server.StatusChanged() return nil } } @@ -216,9 +215,9 @@ func (m *Model) mprisTitleCmd() tea.Cmd { if m.mprisBridge == nil { return nil } - handler := m.mprisBridge.EventHandler() + server := m.mprisBridge.Server() return func() tea.Msg { - _ = handler.Player.OnTitle() + server.MetadataChanged() return nil } } @@ -232,10 +231,10 @@ func (m *Model) mprisPositionCmd() tea.Cmd { return nil } m.lastMprisEmit = now - handler := m.mprisBridge.EventHandler() - pos := types.Microseconds(m.player.Position * 1_000_000) + server := m.mprisBridge.Server() + pos := m.player.Position return func() tea.Msg { - _ = handler.Player.OnSeek(pos) + server.Seeked(pos) return nil } } @@ -244,9 +243,9 @@ func (m *Model) mprisVolumeCmd() tea.Cmd { if m.mprisBridge == nil { return nil } - handler := m.mprisBridge.EventHandler() + server := m.mprisBridge.Server() return func() tea.Msg { - _ = handler.Player.OnVolume() + server.VolumeChanged() return nil } } diff --git a/internal/mpris/adapter.go b/internal/mpris/adapter.go index fb9533a..cd6d0a8 100644 --- a/internal/mpris/adapter.go +++ b/internal/mpris/adapter.go @@ -3,12 +3,29 @@ package mpris import ( "fmt" "strings" + "time" "github.com/godbus/dbus/v5" - "github.com/quarckster/go-mpris-server/pkg/types" ) -// RootAdapter implements types.OrgMprisMediaPlayer2Adapter with static values. +// PlaybackStatus is a value of the MPRIS PlaybackStatus property. +type PlaybackStatus string + +const ( + StatusPlaying PlaybackStatus = "Playing" + StatusPaused PlaybackStatus = "Paused" + StatusStopped PlaybackStatus = "Stopped" +) + +// Metadata is the metadata of the current track. +type Metadata struct { + TrackID dbus.ObjectPath + Length time.Duration + Title string + Artist []string +} + +// RootAdapter supplies the org.mpris.MediaPlayer2 interface with static values. // Name is the app name that MPRIS clients see. type RootAdapter struct { Name string @@ -24,7 +41,7 @@ func (r RootAdapter) SupportedUriSchemes() ([]string, error) { return nil, nil } func (r RootAdapter) SupportedMimeTypes() ([]string, error) { return nil, nil } func (r RootAdapter) DesktopEntry() (string, error) { return r.Name, nil } -// PlayerAdapter implements types.OrgMprisMediaPlayer2PlayerAdapter using closures. +// PlayerAdapter supplies the org.mpris.MediaPlayer2.Player interface using closures. // Each method field is a closure that reads from a ModelAccessor. type PlayerAdapter struct { OnNext func() error @@ -33,13 +50,13 @@ type PlayerAdapter struct { OnPlayPause func() error OnStop func() error OnPlay func() error - OnSeek func(offset types.Microseconds) error - OnSetPosition func(trackId string, position types.Microseconds) error + OnSeek func(offset time.Duration) error + OnSetPosition func(trackId string, position time.Duration) error OnOpenUri func(uri string) error - OnPlaybackStatus func() (types.PlaybackStatus, error) + OnPlaybackStatus func() (PlaybackStatus, error) OnRate func() (float64, error) OnSetRate func(rate float64) error - OnMetadata func() (types.Metadata, error) + OnMetadata func() (Metadata, error) OnVolume func() (float64, error) OnSetVolume func(vol float64) error OnPosition func() (int64, error) @@ -53,32 +70,32 @@ type PlayerAdapter struct { OnCanControl func() (bool, error) } -func (p PlayerAdapter) Next() error { return p.OnNext() } -func (p PlayerAdapter) Previous() error { return p.OnPrevious() } -func (p PlayerAdapter) Pause() error { return p.OnPause() } -func (p PlayerAdapter) PlayPause() error { return p.OnPlayPause() } -func (p PlayerAdapter) Stop() error { return p.OnStop() } -func (p PlayerAdapter) Play() error { return p.OnPlay() } -func (p PlayerAdapter) Seek(offset types.Microseconds) error { return p.OnSeek(offset) } -func (p PlayerAdapter) SetPosition(trackId string, position types.Microseconds) error { +func (p PlayerAdapter) Next() error { return p.OnNext() } +func (p PlayerAdapter) Previous() error { return p.OnPrevious() } +func (p PlayerAdapter) Pause() error { return p.OnPause() } +func (p PlayerAdapter) PlayPause() error { return p.OnPlayPause() } +func (p PlayerAdapter) Stop() error { return p.OnStop() } +func (p PlayerAdapter) Play() error { return p.OnPlay() } +func (p PlayerAdapter) Seek(offset time.Duration) error { return p.OnSeek(offset) } +func (p PlayerAdapter) SetPosition(trackId string, position time.Duration) error { return p.OnSetPosition(trackId, position) } -func (p PlayerAdapter) OpenUri(uri string) error { return p.OnOpenUri(uri) } -func (p PlayerAdapter) PlaybackStatus() (types.PlaybackStatus, error) { return p.OnPlaybackStatus() } -func (p PlayerAdapter) Rate() (float64, error) { return p.OnRate() } -func (p PlayerAdapter) SetRate(rate float64) error { return p.OnSetRate(rate) } -func (p PlayerAdapter) Metadata() (types.Metadata, error) { return p.OnMetadata() } -func (p PlayerAdapter) Volume() (float64, error) { return p.OnVolume() } -func (p PlayerAdapter) SetVolume(vol float64) error { return p.OnSetVolume(vol) } -func (p PlayerAdapter) Position() (int64, error) { return p.OnPosition() } -func (p PlayerAdapter) MinimumRate() (float64, error) { return p.OnMinimumRate() } -func (p PlayerAdapter) MaximumRate() (float64, error) { return p.OnMaximumRate() } -func (p PlayerAdapter) CanGoNext() (bool, error) { return p.OnCanGoNext() } -func (p PlayerAdapter) CanGoPrevious() (bool, error) { return p.OnCanGoPrevious() } -func (p PlayerAdapter) CanPlay() (bool, error) { return p.OnCanPlay() } -func (p PlayerAdapter) CanPause() (bool, error) { return p.OnCanPause() } -func (p PlayerAdapter) CanSeek() (bool, error) { return p.OnCanSeek() } -func (p PlayerAdapter) CanControl() (bool, error) { return p.OnCanControl() } +func (p PlayerAdapter) OpenUri(uri string) error { return p.OnOpenUri(uri) } +func (p PlayerAdapter) PlaybackStatus() (PlaybackStatus, error) { return p.OnPlaybackStatus() } +func (p PlayerAdapter) Rate() (float64, error) { return p.OnRate() } +func (p PlayerAdapter) SetRate(rate float64) error { return p.OnSetRate(rate) } +func (p PlayerAdapter) Metadata() (Metadata, error) { return p.OnMetadata() } +func (p PlayerAdapter) Volume() (float64, error) { return p.OnVolume() } +func (p PlayerAdapter) SetVolume(vol float64) error { return p.OnSetVolume(vol) } +func (p PlayerAdapter) Position() (int64, error) { return p.OnPosition() } +func (p PlayerAdapter) MinimumRate() (float64, error) { return p.OnMinimumRate() } +func (p PlayerAdapter) MaximumRate() (float64, error) { return p.OnMaximumRate() } +func (p PlayerAdapter) CanGoNext() (bool, error) { return p.OnCanGoNext() } +func (p PlayerAdapter) CanGoPrevious() (bool, error) { return p.OnCanGoPrevious() } +func (p PlayerAdapter) CanPlay() (bool, error) { return p.OnCanPlay() } +func (p PlayerAdapter) CanPause() (bool, error) { return p.OnCanPause() } +func (p PlayerAdapter) CanSeek() (bool, error) { return p.OnCanSeek() } +func (p PlayerAdapter) CanControl() (bool, error) { return p.OnCanControl() } // NewPlayerAdapter creates a PlayerAdapter whose closures read from the accessor. // name is the app name used in track object paths. @@ -94,31 +111,29 @@ func NewPlayerAdapter(name string, accessor func() ModelAccessor, actions Player OnSeek: actions.Seek, OnSetPosition: actions.SetPosition, OnOpenUri: func(string) error { return nil }, - OnPlaybackStatus: func() (types.PlaybackStatus, error) { + OnPlaybackStatus: func() (PlaybackStatus, error) { a := accessor() if a.IsPlaying() { - return types.PlaybackStatusPlaying, nil + return StatusPlaying, nil } if a.IsPaused() { - return types.PlaybackStatusPaused, nil + return StatusPaused, nil } - return types.PlaybackStatusStopped, nil + return StatusStopped, nil }, OnRate: func() (float64, error) { return accessor().PlayerSpeed(), nil }, OnSetRate: actions.SetRate, - OnMetadata: func() (types.Metadata, error) { + OnMetadata: func() (Metadata, error) { a := accessor() title := a.CurrentTitle() if title == "" { - return types.Metadata{ - TrackId: dbus.ObjectPath("/org/mpris/MediaPlayer2/NoTrack"), + return Metadata{ + TrackID: dbus.ObjectPath("/org/mpris/MediaPlayer2/NoTrack"), }, nil } - trackID := trackPath(name, a.CurrentItemID()) - durationMicro := types.Microseconds(a.PlayerDuration() * 1_000_000) - return types.Metadata{ - TrackId: trackID, - Length: durationMicro, + return Metadata{ + TrackID: trackPath(name, a.CurrentItemID()), + Length: time.Duration(a.PlayerDuration() * float64(time.Second)), Title: title, Artist: a.CurrentAuthors(), }, nil @@ -156,8 +171,8 @@ type PlayerActions struct { PlayPause func() error Stop func() error Play func() error - Seek func(offset types.Microseconds) error - SetPosition func(trackId string, position types.Microseconds) error + Seek func(offset time.Duration) error + SetPosition func(trackId string, position time.Duration) error SetRate func(rate float64) error SetVolume func(vol int) error } diff --git a/internal/mpris/bridge.go b/internal/mpris/bridge.go index 1048da9..80bc6d2 100644 --- a/internal/mpris/bridge.go +++ b/internal/mpris/bridge.go @@ -4,10 +4,9 @@ import ( "errors" "fmt" "sync" + "time" tea "github.com/charmbracelet/bubbletea" - "github.com/quarckster/go-mpris-server/pkg/events" - "github.com/quarckster/go-mpris-server/pkg/types" "github.com/Thelost77/pine/internal/logger" ) @@ -93,18 +92,18 @@ func (b *Bridge) Bind(accessor func() ModelAccessor, skipSeekSeconds float64) { b.send(PlayMsg{}) return nil }, - Seek: func(offset types.Microseconds) error { - b.send(SeekRelativeMsg{OffsetSeconds: float64(offset) / 1_000_000}) + Seek: func(offset time.Duration) error { + b.send(SeekRelativeMsg{OffsetSeconds: offset.Seconds()}) return nil }, - SetPosition: func(trackId string, pos types.Microseconds) error { + SetPosition: func(trackId string, pos time.Duration) error { // MPRIS: SetPosition has no effect if CanSeek is false, and a request // for a track that is not the current track is stale. a := accessor() if !a.CanSeek() || trackId != string(trackPath(b.name, a.CurrentItemID())) { return nil } - b.send(SeekAbsoluteMsg{PositionSeconds: float64(pos) / 1_000_000}) + b.send(SeekAbsoluteMsg{PositionSeconds: pos.Seconds()}) return nil }, SetRate: func(rate float64) error { @@ -171,10 +170,7 @@ func (b *Bridge) Stop() error { return err } -// EventHandler returns the MPRIS event handler for emitting property changes. -func (b *Bridge) EventHandler() *events.EventHandler { - if b.server == nil { - return nil - } - return b.server.EventHandler() +// Server returns the MPRIS server. It is nil before Bind. +func (b *Bridge) Server() *Server { + return b.server } diff --git a/internal/mpris/bridge_test.go b/internal/mpris/bridge_test.go index defcc5b..eb50f32 100644 --- a/internal/mpris/bridge_test.go +++ b/internal/mpris/bridge_test.go @@ -3,6 +3,7 @@ package mpris import ( "fmt" "testing" + "time" ) func TestBridgeMapsPlaybackActionsToExplicitMessages(t *testing.T) { @@ -75,7 +76,7 @@ func TestBridgeSetPositionAcceptsThePublishedTrackID(t *testing.T) { if err != nil { t.Fatalf("Metadata() error: %v", err) } - if err := bridge.player.SetPosition(string(meta.TrackId), 30_000_000); err != nil { + if err := bridge.player.SetPosition(string(meta.TrackID), 30*time.Second); err != nil { t.Fatalf("SetPosition() error: %v", err) } @@ -105,7 +106,7 @@ func TestBridgeSetPositionIgnoresOtherTracks(t *testing.T) { bridge := NewBridge(nil, "spruce") bridge.Bind(accessorFn(tt.accessor), 0) - if err := bridge.player.SetPosition(tt.trackID, 30_000_000); err != nil { + if err := bridge.player.SetPosition(tt.trackID, 30*time.Second); err != nil { t.Fatalf("SetPosition() error: %v", err) } diff --git a/internal/mpris/mpris_test.go b/internal/mpris/mpris_test.go index cd2dfb6..bdcb498 100644 --- a/internal/mpris/mpris_test.go +++ b/internal/mpris/mpris_test.go @@ -2,9 +2,9 @@ package mpris import ( "testing" + "time" "github.com/godbus/dbus/v5" - "github.com/quarckster/go-mpris-server/pkg/types" ) type mockAccessor struct { @@ -81,11 +81,11 @@ func TestPlayerAdapterPlaybackStatus(t *testing.T) { tests := []struct { name string accessor *mockAccessor - want types.PlaybackStatus + want PlaybackStatus }{ - {"playing", &mockAccessor{playing: true}, types.PlaybackStatusPlaying}, - {"paused", &mockAccessor{paused: true}, types.PlaybackStatusPaused}, - {"stopped", &mockAccessor{}, types.PlaybackStatusStopped}, + {"playing", &mockAccessor{playing: true}, StatusPlaying}, + {"paused", &mockAccessor{paused: true}, StatusPaused}, + {"stopped", &mockAccessor{}, StatusStopped}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -166,12 +166,12 @@ func TestPlayerAdapterMetadata(t *testing.T) { if len(meta.Artist) != 1 || meta.Artist[0] != "J.R.R. Tolkien" { t.Errorf("Metadata().Artist = %v; want [J.R.R. Tolkien]", meta.Artist) } - if meta.Length != types.Microseconds(3600*1_000_000) { - t.Errorf("Metadata().Length = %d; want %d", meta.Length, 3600*1_000_000) + if meta.Length != time.Hour { + t.Errorf("Metadata().Length = %v; want %v", meta.Length, time.Hour) } expectedPath := dbus.ObjectPath("/org/spruce/track/item_123") - if meta.TrackId != expectedPath { - t.Errorf("Metadata().TrackId = %q; want %q", meta.TrackId, expectedPath) + if meta.TrackID != expectedPath { + t.Errorf("Metadata().TrackID = %q; want %q", meta.TrackID, expectedPath) } } @@ -202,9 +202,9 @@ func TestPlayerAdapterPosition(t *testing.T) { } func TestPlayerAdapterSeek(t *testing.T) { - var capturedOffset types.Microseconds + var capturedOffset time.Duration actions := PlayerActions{ - Seek: func(offset types.Microseconds) error { + Seek: func(offset time.Duration) error { capturedOffset = offset return nil }, @@ -212,11 +212,11 @@ func TestPlayerAdapterSeek(t *testing.T) { a := &mockAccessor{} p := NewPlayerAdapter("spruce", accessorFn(a), actions) - if err := p.Seek(types.Microseconds(10_000_000)); err != nil { + if err := p.Seek(10 * time.Second); err != nil { t.Fatalf("Seek() error: %v", err) } - if capturedOffset != 10_000_000 { - t.Errorf("Seek(10_000_000) captured = %d; want 10000000", capturedOffset) + if capturedOffset != 10*time.Second { + t.Errorf("Seek(10s) captured = %v; want 10s", capturedOffset) } } diff --git a/internal/mpris/server.go b/internal/mpris/server.go index b4640c3..08d7413 100644 --- a/internal/mpris/server.go +++ b/internal/mpris/server.go @@ -1,37 +1,360 @@ package mpris import ( - "github.com/quarckster/go-mpris-server/pkg/events" - "github.com/quarckster/go-mpris-server/pkg/server" - "github.com/quarckster/go-mpris-server/pkg/types" + "fmt" + "sync" + "time" + + "github.com/godbus/dbus/v5" + "github.com/godbus/dbus/v5/introspect" + "github.com/godbus/dbus/v5/prop" +) + +// The object path and the interface names of MPRIS 2.2. +const ( + objectPath = dbus.ObjectPath("/org/mpris/MediaPlayer2") + rootInterface = "org.mpris.MediaPlayer2" + playerInterface = "org.mpris.MediaPlayer2.Player" + propertiesInterface = "org.freedesktop.DBus.Properties" + introspectableInterface = "org.freedesktop.DBus.Introspectable" ) -// Server wraps the MPRIS D-Bus server and event handler. +// property is one D-Bus property. The server reads the value from the app on +// each request. +type property struct { + name string + signature string + get func() (any, error) + // set is nil for a read-only property. The writable MPRIS properties that + // the server has are of the type double. + set func(float64) error +} + +func readOnly[T any](name, signature string, get func() (T, error)) property { + return property{name: name, signature: signature, get: func() (any, error) { return get() }} +} + +func readWrite(name string, get func() (float64, error), set func(float64) error) property { + p := readOnly(name, "d", get) + p.set = set + return p +} + +// Server is an MPRIS media player on the D-Bus session bus. It has the +// org.mpris.MediaPlayer2 and org.mpris.MediaPlayer2.Player interfaces. type Server struct { - srv *server.Server - events *events.EventHandler + name string + root RootAdapter + player PlayerAdapter + properties map[string][]property + + mu sync.Mutex + conn *dbus.Conn + stopped bool } // NewServer creates an MPRIS server for the app name with the given root and player adapters. -func NewServer(name string, root types.OrgMprisMediaPlayer2Adapter, player types.OrgMprisMediaPlayer2PlayerAdapter) *Server { - srv := server.NewServer(name, root, player) +func NewServer(name string, root RootAdapter, player PlayerAdapter) *Server { + status := func() (string, error) { + status, err := player.PlaybackStatus() + return string(status), err + } + metadata := func() (map[string]dbus.Variant, error) { + metadata, err := player.Metadata() + return metadata.variants(), err + } return &Server{ - srv: srv, - events: events.NewEventHandler(srv), + name: name, + root: root, + player: player, + properties: map[string][]property{ + rootInterface: { + readOnly("CanQuit", "b", root.CanQuit), + readOnly("CanRaise", "b", root.CanRaise), + readOnly("HasTrackList", "b", root.HasTrackList), + readOnly("Identity", "s", root.Identity), + readOnly("DesktopEntry", "s", root.DesktopEntry), + readOnly("SupportedUriSchemes", "as", root.SupportedUriSchemes), + readOnly("SupportedMimeTypes", "as", root.SupportedMimeTypes), + }, + playerInterface: { + readOnly("PlaybackStatus", "s", status), + readWrite("Rate", player.Rate, player.SetRate), + readOnly("Metadata", "a{sv}", metadata), + readWrite("Volume", player.Volume, player.SetVolume), + readOnly("Position", "x", player.Position), + readOnly("MinimumRate", "d", player.MinimumRate), + readOnly("MaximumRate", "d", player.MaximumRate), + readOnly("CanGoNext", "b", player.CanGoNext), + readOnly("CanGoPrevious", "b", player.CanGoPrevious), + readOnly("CanPlay", "b", player.CanPlay), + readOnly("CanPause", "b", player.CanPause), + readOnly("CanSeek", "b", player.CanSeek), + readOnly("CanControl", "b", player.CanControl), + }, + }, + } +} + +// variants returns the metadata as an MPRIS metadata map. An empty field is +// not in the map. +func (m Metadata) variants() map[string]dbus.Variant { + values := map[string]dbus.Variant{"mpris:trackid": dbus.MakeVariant(m.TrackID)} + if m.Length > 0 { + values["mpris:length"] = dbus.MakeVariant(m.Length.Microseconds()) + } + if m.Title != "" { + values["xesam:title"] = dbus.MakeVariant(m.Title) + } + if len(m.Artist) > 0 { + values["xesam:artist"] = dbus.MakeVariant(m.Artist) } + return values } -// Listen starts the D-Bus server. Blocks until Stop is called. +// Listen connects to the session bus and answers MPRIS requests. It blocks +// until Stop is called or the connection closes. func (s *Server) Listen() error { - return s.srv.Listen() + conn, err := dbus.ConnectSessionBus() + if err != nil { + return err + } + if err := s.export(conn); err != nil { + _ = conn.Close() + return err + } + s.mu.Lock() + if s.stopped { + s.mu.Unlock() + return conn.Close() + } + s.conn = conn + s.mu.Unlock() + <-conn.Context().Done() + return nil } -// Stop releases the D-Bus name and closes the connection. +// Stop closes the connection to the session bus. The bus then releases the name. func (s *Server) Stop() error { - return s.srv.Stop() + s.mu.Lock() + defer s.mu.Unlock() + s.stopped = true + if s.conn == nil { + return nil + } + conn := s.conn + s.conn = nil + return conn.Close() } -// EventHandler returns the event handler for emitting property changes. -func (s *Server) EventHandler() *events.EventHandler { - return s.events +// export puts the MPRIS object on the connection and then takes the bus name. +func (s *Server) export(conn *dbus.Conn) error { + player := s.player + call := func(method func() error) func() *dbus.Error { + return func() *dbus.Error { return failed(method()) } + } + for iface, methods := range map[string]map[string]any{ + rootInterface: { + "Raise": call(s.root.Raise), + "Quit": call(s.root.Quit), + }, + playerInterface: { + "Next": call(player.Next), + "Previous": call(player.Previous), + "Pause": call(player.Pause), + "PlayPause": call(player.PlayPause), + "Stop": call(player.Stop), + "Play": call(player.Play), + "Seek": func(offset int64) *dbus.Error { + return failed(player.Seek(microseconds(offset))) + }, + "SetPosition": func(trackID dbus.ObjectPath, position int64) *dbus.Error { + return failed(player.SetPosition(string(trackID), microseconds(position))) + }, + "OpenUri": func(uri string) *dbus.Error { return failed(player.OpenUri(uri)) }, + }, + propertiesInterface: { + "Get": s.get, + "GetAll": s.getAll, + "Set": s.set, + }, + introspectableInterface: { + "Introspect": introspect.NewIntrospectable(s.node()).Introspect, + }, + } { + if err := conn.ExportMethodTable(methods, objectPath, iface); err != nil { + return err + } + } + + busName := "org.mpris.MediaPlayer2." + s.name + reply, err := conn.RequestName(busName, dbus.NameFlagDoNotQueue) + if err != nil { + return err + } + if reply != dbus.RequestNameReplyPrimaryOwner { + return fmt.Errorf("the D-Bus name %s is not available", busName) + } + return nil +} + +func microseconds(value int64) time.Duration { + return time.Duration(value) * time.Microsecond +} + +func failed(err error) *dbus.Error { + if err == nil { + return nil + } + return dbus.MakeFailedError(err) +} + +func (s *Server) find(iface, name string) (property, *dbus.Error) { + properties, ok := s.properties[iface] + if !ok { + return property{}, prop.ErrIfaceNotFound + } + for _, p := range properties { + if p.name == name { + return p, nil + } + } + return property{}, prop.ErrPropNotFound +} + +func (s *Server) get(iface, name string) (dbus.Variant, *dbus.Error) { + p, dbusErr := s.find(iface, name) + if dbusErr != nil { + return dbus.Variant{}, dbusErr + } + value, err := p.get() + if err != nil { + return dbus.Variant{}, dbus.MakeFailedError(err) + } + return dbus.MakeVariant(value), nil +} + +func (s *Server) getAll(iface string) (map[string]dbus.Variant, *dbus.Error) { + properties, ok := s.properties[iface] + if !ok { + return nil, prop.ErrIfaceNotFound + } + values := make(map[string]dbus.Variant, len(properties)) + for _, p := range properties { + value, err := p.get() + if err != nil { + return nil, dbus.MakeFailedError(err) + } + values[p.name] = dbus.MakeVariant(value) + } + return values, nil +} + +func (s *Server) set(iface, name string, value dbus.Variant) *dbus.Error { + p, dbusErr := s.find(iface, name) + if dbusErr != nil { + return dbusErr + } + if p.set == nil { + return prop.ErrReadOnly + } + number, ok := value.Value().(float64) + if !ok { + return prop.ErrInvalidArg + } + return failed(p.set(number)) +} + +// node returns the introspection data of the MPRIS object. +func (s *Server) node() *introspect.Node { + in := func(name, signature string) introspect.Arg { + return introspect.Arg{Name: name, Type: signature, Direction: "in"} + } + properties := func(iface string) []introspect.Property { + var result []introspect.Property + for _, p := range s.properties[iface] { + access := "read" + if p.set != nil { + access = "readwrite" + } + result = append(result, introspect.Property{Name: p.name, Type: p.signature, Access: access}) + } + return result + } + return &introspect.Node{ + Name: string(objectPath), + Interfaces: []introspect.Interface{ + introspect.IntrospectData, + prop.IntrospectData, + { + Name: rootInterface, + Methods: []introspect.Method{{Name: "Raise"}, {Name: "Quit"}}, + Properties: properties(rootInterface), + }, + { + Name: playerInterface, + Methods: []introspect.Method{ + {Name: "Next"}, + {Name: "Previous"}, + {Name: "Pause"}, + {Name: "PlayPause"}, + {Name: "Stop"}, + {Name: "Play"}, + {Name: "Seek", Args: []introspect.Arg{in("Offset", "x")}}, + {Name: "SetPosition", Args: []introspect.Arg{in("TrackId", "o"), in("Position", "x")}}, + {Name: "OpenUri", Args: []introspect.Arg{in("Uri", "s")}}, + }, + Signals: []introspect.Signal{ + {Name: "Seeked", Args: []introspect.Arg{{Name: "Position", Type: "x"}}}, + }, + Properties: properties(playerInterface), + }, + }, + } +} + +func (s *Server) connection() *dbus.Conn { + s.mu.Lock() + defer s.mu.Unlock() + return s.conn +} + +// changed sends the current values of the given player properties to the +// MPRIS clients. +func (s *Server) changed(names ...string) { + conn := s.connection() + if conn == nil { + return + } + values := make(map[string]dbus.Variant, len(names)) + for _, name := range names { + if value, err := s.get(playerInterface, name); err == nil { + values[name] = value + } + } + _ = conn.Emit(objectPath, propertiesInterface+".PropertiesChanged", playerInterface, values, []string{}) +} + +// PlaybackChanged tells MPRIS clients that the playback state, the rate, or +// the track changed. +func (s *Server) PlaybackChanged() { + s.changed("PlaybackStatus", "Rate", "Metadata", "MinimumRate", "MaximumRate") +} + +// StatusChanged tells MPRIS clients that the playback status changed. +func (s *Server) StatusChanged() { s.changed("PlaybackStatus") } + +// MetadataChanged tells MPRIS clients that the track metadata changed. +func (s *Server) MetadataChanged() { s.changed("Metadata") } + +// VolumeChanged tells MPRIS clients that the volume changed. +func (s *Server) VolumeChanged() { s.changed("Volume") } + +// Seeked tells MPRIS clients the playback position, in seconds. +func (s *Server) Seeked(positionSeconds float64) { + conn := s.connection() + if conn == nil { + return + } + _ = conn.Emit(objectPath, playerInterface+".Seeked", int64(positionSeconds*1_000_000)) } diff --git a/internal/mpris/server_linux_test.go b/internal/mpris/server_linux_test.go new file mode 100644 index 0000000..cdfbc10 --- /dev/null +++ b/internal/mpris/server_linux_test.go @@ -0,0 +1,425 @@ +//go:build linux + +package mpris + +import ( + "bufio" + "context" + "os" + "os/exec" + "reflect" + "strings" + "testing" + "time" + + "github.com/godbus/dbus/v5" + "github.com/godbus/dbus/v5/introspect" +) + +const ( + testBusName = "org.mpris.MediaPlayer2.pinetest" + testObjectPath = "/org/mpris/MediaPlayer2" + testRootIface = "org.mpris.MediaPlayer2" + testPlayerIface = "org.mpris.MediaPlayer2.Player" + testPropsIface = "org.freedesktop.DBus.Properties" +) + +// startSessionBus starts a private D-Bus session bus and returns its address. +// Without dbus-daemon the test skips. With PINE_REQUIRE_DBUS=1 it fails, so +// that a release cannot skip this test. +func startSessionBus(t *testing.T) string { + t.Helper() + path, err := exec.LookPath("dbus-daemon") + if err != nil { + if os.Getenv("PINE_REQUIRE_DBUS") == "1" { + t.Fatalf("dbus-daemon is required: %v", err) + } + t.Skipf("dbus-daemon is not available: %v", err) + } + cmd := exec.Command(path, "--session", "--nofork", "--print-address") + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatalf("StdoutPipe() error: %v", err) + } + if err := cmd.Start(); err != nil { + t.Fatalf("start dbus-daemon: %v", err) + } + t.Cleanup(func() { + _ = cmd.Process.Kill() + _ = cmd.Wait() + }) + address, err := bufio.NewReader(stdout).ReadString('\n') + if err != nil { + t.Fatalf("read the address of dbus-daemon: %v", err) + } + return strings.TrimSpace(address) +} + +// busCall calls a D-Bus method. It fails the test if the server does not +// answer. +func busCall(t *testing.T, obj dbus.BusObject, method string, args ...any) *dbus.Call { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + call := obj.CallWithContext(ctx, method, 0, args...) + if ctx.Err() != nil { + t.Fatalf("%s: no answer from the server", method) + } + return call +} + +func errorName(err error) string { + if dbusErr, ok := err.(dbus.Error); ok { + return dbusErr.Name + } + return "" +} + +// TestServerOnSessionBus checks the server as an MPRIS client sees it. +func TestServerOnSessionBus(t *testing.T) { + address := startSessionBus(t) + // The server must not connect to the session bus of the desktop. + t.Setenv("DBUS_SESSION_BUS_ADDRESS", address) + + state := &mockAccessor{ + playing: true, + hasItem: true, + title: "The Hobbit", + authors: []string{"J.R.R. Tolkien"}, + itemID: "item-123", + position: 42.5, + duration: 3600, + volume: 75, + speed: 1.5, + queueLen: 2, + } + bridge := NewBridge(nil, "pinetest") + // With a skip of 10 seconds, Next and Previous seek and return an error. + bridge.Bind(accessorFn(state), 10) + server := bridge.Server() + listenErr := make(chan error, 1) + go func() { listenErr <- server.Listen() }() + t.Cleanup(func() { _ = server.Stop() }) + + client, err := dbus.Connect(address) + if err != nil { + t.Fatalf("connect to the test bus: %v", err) + } + t.Cleanup(func() { _ = client.Close() }) + + deadline := time.Now().Add(5 * time.Second) + for { + var owned bool + if err := busCall(t, client.BusObject(), "org.freedesktop.DBus.NameHasOwner", testBusName).Store(&owned); err != nil { + t.Fatalf("NameHasOwner error: %v", err) + } + if owned { + break + } + select { + case err := <-listenErr: + t.Fatalf("Listen() returned before the name was owned: %v", err) + default: + } + if time.Now().After(deadline) { + t.Fatalf("the server did not take the name %s", testBusName) + } + time.Sleep(10 * time.Millisecond) + } + obj := client.Object(testBusName, testObjectPath) + + getAll := func(t *testing.T, iface string) map[string]dbus.Variant { + t.Helper() + var props map[string]dbus.Variant + if err := busCall(t, obj, testPropsIface+".GetAll", iface).Store(&props); err != nil { + t.Fatalf("GetAll(%s) error: %v", iface, err) + } + return props + } + checkValues := func(t *testing.T, got map[string]dbus.Variant, want map[string]any) { + t.Helper() + for name, wantValue := range want { + variant, ok := got[name] + if !ok { + t.Errorf("%s is absent", name) + continue + } + if !reflect.DeepEqual(variant.Value(), wantValue) { + t.Errorf("%s = %#v (%s); want %#v", name, variant.Value(), variant.Signature(), wantValue) + } + } + } + wantMetadata := map[string]any{ + "mpris:trackid": dbus.ObjectPath("/org/pinetest/track/item_123"), + "mpris:length": int64(3_600_000_000), + "xesam:title": "The Hobbit", + "xesam:artist": []string{"J.R.R. Tolkien"}, + } + wantRoot := map[string]any{ + "CanQuit": true, + "CanRaise": false, + "HasTrackList": false, + "Identity": "pinetest", + "DesktopEntry": "pinetest", + "SupportedUriSchemes": []string{}, + "SupportedMimeTypes": []string{}, + } + wantPlayer := map[string]any{ + "PlaybackStatus": "Playing", + "Rate": 1.5, + "Volume": 0.75, + "Position": int64(42_500_000), + "MinimumRate": 0.5, + "MaximumRate": 4.0, + "CanGoNext": true, + "CanGoPrevious": true, + "CanPlay": true, + "CanPause": true, + "CanSeek": true, + "CanControl": true, + } + + t.Run("properties", func(t *testing.T) { + root := getAll(t, testRootIface) + checkValues(t, root, wantRoot) + if len(root) != len(wantRoot) { + t.Errorf("root interface has %d properties; want %d", len(root), len(wantRoot)) + } + + player := getAll(t, testPlayerIface) + checkValues(t, player, wantPlayer) + // Metadata is the one property that is not in wantPlayer. + if len(player) != len(wantPlayer)+1 { + t.Errorf("player interface has %d properties; want %d", len(player), len(wantPlayer)+1) + } + metadata, ok := player["Metadata"].Value().(map[string]dbus.Variant) + if !ok { + t.Fatalf("Metadata has signature %s; want a{sv}", player["Metadata"].Signature()) + } + checkValues(t, metadata, wantMetadata) + + var position dbus.Variant + if err := busCall(t, obj, testPropsIface+".Get", testPlayerIface, "Position").Store(&position); err != nil { + t.Fatalf("Get(Position) error: %v", err) + } + if position.Value() != int64(42_500_000) { + t.Errorf("Get(Position) = %#v; want int64(42500000)", position.Value()) + } + }) + + t.Run("property errors", func(t *testing.T) { + err := busCall(t, obj, testPropsIface+".Get", testPlayerIface, "NoSuchProperty").Err + if got, want := errorName(err), "org.freedesktop.DBus.Properties.Error.PropertyNotFound"; got != want { + t.Errorf("Get(NoSuchProperty) error = %q (%v); want %q", got, err, want) + } + err = busCall(t, obj, testPropsIface+".GetAll", "org.example.NoSuchInterface").Err + if got, want := errorName(err), "org.freedesktop.DBus.Properties.Error.InterfaceNotFound"; got != want { + t.Errorf("GetAll(NoSuchInterface) error = %q (%v); want %q", got, err, want) + } + err = busCall(t, obj, testPropsIface+".Set", testPlayerIface, "Position", dbus.MakeVariant(int64(1))).Err + if err == nil { + t.Error("Set(Position) returned no error; Position is read-only") + } + }) + + t.Run("introspection", func(t *testing.T) { + node, err := introspect.Call(obj) + if err != nil { + t.Fatalf("Introspect error: %v", err) + } + interfaces := make(map[string]introspect.Interface) + for _, iface := range node.Interfaces { + interfaces[iface.Name] = iface + } + if _, ok := interfaces[testPropsIface]; !ok { + t.Errorf("introspection data does not contain %s", testPropsIface) + } + // The type of each property in the introspection data must be the + // type that the server sends. + for _, name := range []string{testRootIface, testPlayerIface} { + declared := make(map[string]string) + for _, property := range interfaces[name].Properties { + declared[property.Name] = property.Type + } + for property, variant := range getAll(t, name) { + if got, want := declared[property], variant.Signature().String(); got != want { + t.Errorf("%s.%s: introspection type = %q; the server sends %q", name, property, got, want) + } + } + } + methods := make(map[string]bool) + for _, method := range interfaces[testPlayerIface].Methods { + methods[method.Name] = true + } + for _, name := range []string{"Next", "Previous", "Pause", "PlayPause", "Stop", "Play", "Seek", "SetPosition", "OpenUri"} { + if !methods[name] { + t.Errorf("introspection data does not contain the method %s", name) + } + } + if signals := interfaces[testPlayerIface].Signals; len(signals) != 1 || signals[0].Name != "Seeked" { + t.Errorf("signals of the player interface = %v; want Seeked", signals) + } + }) + + // wantMessage checks the message that a call must send to the app. + wantMessage := func(t *testing.T, name string, want any) { + t.Helper() + select { + case got := <-bridge.msgCh: + if got != want { + t.Errorf("%s sent %#v; want %#v", name, got, want) + } + default: + t.Errorf("%s sent no message; want %#v", name, want) + } + } + + t.Run("methods", func(t *testing.T) { + for _, tt := range []struct { + method string + args []any + want any + wantError bool + }{ + {method: "Play", want: PlayMsg{}}, + {method: "Pause", want: PauseMsg{}}, + {method: "PlayPause", want: PlayPauseMsg{}}, + {method: "Stop", want: PauseMsg{}}, + {method: "Seek", args: []any{int64(5_000_000)}, want: SeekRelativeMsg{OffsetSeconds: 5}}, + { + method: "SetPosition", + args: []any{dbus.ObjectPath("/org/pinetest/track/item_123"), int64(30_000_000)}, + want: SeekAbsoluteMsg{PositionSeconds: 30}, + }, + // The error stops the desktop wait for a track change. + {method: "Next", want: SeekRelativeMsg{OffsetSeconds: 10}, wantError: true}, + {method: "Previous", want: SeekRelativeMsg{OffsetSeconds: -10}, wantError: true}, + } { + err := busCall(t, obj, testPlayerIface+"."+tt.method, tt.args...).Err + if tt.wantError { + if got, want := errorName(err), "org.freedesktop.DBus.Error.Failed"; got != want { + t.Errorf("%s error = %q (%v); want %q", tt.method, got, err, want) + } + } else if err != nil { + t.Errorf("%s error: %v", tt.method, err) + } + wantMessage(t, tt.method, tt.want) + } + for _, method := range []string{testPlayerIface + ".OpenUri", testRootIface + ".Raise", testRootIface + ".Quit"} { + var args []any + if strings.HasSuffix(method, ".OpenUri") { + args = []any{"file:///book.m4b"} + } + if err := busCall(t, obj, method, args...).Err; err != nil { + t.Errorf("%s error: %v", method, err) + } + } + }) + + t.Run("set properties", func(t *testing.T) { + if err := busCall(t, obj, testPropsIface+".Set", testPlayerIface, "Volume", dbus.MakeVariant(0.5)).Err; err != nil { + t.Errorf("Set(Volume) error: %v", err) + } + wantMessage(t, "Set(Volume)", SetVolumeMsg{Volume: 50}) + if err := busCall(t, obj, testPropsIface+".Set", testPlayerIface, "Rate", dbus.MakeVariant(2.0)).Err; err != nil { + t.Errorf("Set(Rate) error: %v", err) + } + wantMessage(t, "Set(Rate)", SetRateMsg{Rate: 2}) + + // A value of a wrong type must give an error. It must not reach the app. + err := busCall(t, obj, testPropsIface+".Set", testPlayerIface, "Volume", dbus.MakeVariant("loud")).Err + if got, want := errorName(err), "org.freedesktop.DBus.Properties.Error.InvalidArg"; got != want { + t.Errorf("Set(Volume) with a string: error = %q (%v); want %q", got, err, want) + } + select { + case got := <-bridge.msgCh: + t.Errorf("Set(Volume) with a string sent %#v; want no message", got) + default: + } + }) + + t.Run("signals", func(t *testing.T) { + if err := client.AddMatchSignal(dbus.WithMatchObjectPath(testObjectPath)); err != nil { + t.Fatalf("AddMatchSignal error: %v", err) + } + signals := make(chan *dbus.Signal, 16) + client.Signal(signals) + // waitSignal returns the body of the next signal with the given name. + waitSignal := func(t *testing.T, name string) []any { + t.Helper() + timeout := time.After(5 * time.Second) + for { + select { + case signal := <-signals: + if signal.Name == name { + return signal.Body + } + case <-timeout: + t.Fatalf("no %s signal", name) + } + } + } + // waitChanged returns the properties of the next PropertiesChanged + // signal of the player interface. + waitChanged := func(t *testing.T) map[string]dbus.Variant { + t.Helper() + body := waitSignal(t, testPropsIface+".PropertiesChanged") + if len(body) != 3 || body[0] != testPlayerIface { + t.Fatalf("PropertiesChanged body = %v; want the player interface and two more values", body) + } + changed, ok := body[1].(map[string]dbus.Variant) + if !ok { + t.Fatalf("PropertiesChanged body[1] = %#v; want a{sv}", body[1]) + } + return changed + } + + server.PlaybackChanged() + changed := waitChanged(t) + checkValues(t, changed, map[string]any{"PlaybackStatus": "Playing", "Rate": 1.5, "MinimumRate": 0.5, "MaximumRate": 4.0}) + metadata, ok := changed["Metadata"].Value().(map[string]dbus.Variant) + if !ok { + t.Fatalf("PlaybackChanged sent Metadata with signature %s; want a{sv}", changed["Metadata"].Signature()) + } + checkValues(t, metadata, wantMetadata) + + for _, tt := range []struct { + name string + emit func() + want map[string]any + }{ + {name: "StatusChanged", emit: server.StatusChanged, want: map[string]any{"PlaybackStatus": "Playing"}}, + {name: "VolumeChanged", emit: server.VolumeChanged, want: map[string]any{"Volume": 0.75}}, + } { + tt.emit() + changed := waitChanged(t) + checkValues(t, changed, tt.want) + if len(changed) != len(tt.want) { + t.Errorf("%s sent %d properties; want %d", tt.name, len(changed), len(tt.want)) + } + } + + server.MetadataChanged() + changed = waitChanged(t) + metadata, ok = changed["Metadata"].Value().(map[string]dbus.Variant) + if !ok || len(changed) != 1 { + t.Fatalf("MetadataChanged sent %v; want only Metadata", changed) + } + checkValues(t, metadata, wantMetadata) + + server.Seeked(42.5) + body := waitSignal(t, testPlayerIface+".Seeked") + if len(body) != 1 || body[0] != int64(42_500_000) { + t.Errorf("Seeked body = %#v; want int64(42500000)", body) + } + }) + + if err := server.Stop(); err != nil { + t.Logf("Stop() error: %v", err) + } + select { + case <-listenErr: + case <-time.After(5 * time.Second): + t.Error("Listen() did not return after Stop()") + } +} diff --git a/internal/spruce/app/playback.go b/internal/spruce/app/playback.go index 70dc2ea..3d46554 100644 --- a/internal/spruce/app/playback.go +++ b/internal/spruce/app/playback.go @@ -7,8 +7,6 @@ import ( "strconv" "time" - "github.com/quarckster/go-mpris-server/pkg/types" - "github.com/Thelost77/pine/internal/logger" "github.com/Thelost77/pine/internal/player" tea "github.com/charmbracelet/bubbletea" @@ -290,8 +288,8 @@ func (m *Model) syncQueueScreen() { }) if m.mprisBridge != nil { - handler := m.mprisBridge.EventHandler() - if handler != nil && handler.Player != nil { + server := m.mprisBridge.Server() + if server != nil { playbackChanged := oldState.playing != newState.playing || oldState.paused != newState.paused || oldState.speed != newState.speed @@ -302,20 +300,19 @@ func (m *Model) syncQueueScreen() { positionChanged := oldState.position != newState.position if playbackChanged { - _ = handler.Player.OnPlayback() + server.PlaybackChanged() } if metadataChanged { - _ = handler.Player.OnTitle() + server.MetadataChanged() } if volumeChanged { - _ = handler.Player.OnVolume() + server.VolumeChanged() } if positionChanged { now := time.Now() if now.Sub(m.lastMprisEmit) >= time.Second { m.lastMprisEmit = now - pos := types.Microseconds(newState.position * 1_000_000) - _ = handler.Player.OnSeek(pos) + server.Seeked(newState.position) } } } @@ -359,12 +356,12 @@ func (m *Model) mprisPlaybackCmd() tea.Cmd { if m.mprisBridge == nil { return nil } - handler := m.mprisBridge.EventHandler() - if handler == nil || handler.Player == nil { + server := m.mprisBridge.Server() + if server == nil { return nil } return func() tea.Msg { - _ = handler.Player.OnPlayback() + server.PlaybackChanged() return nil } } @@ -373,12 +370,12 @@ func (m *Model) mprisPlayPauseCmd() tea.Cmd { if m.mprisBridge == nil { return nil } - handler := m.mprisBridge.EventHandler() - if handler == nil || handler.Player == nil { + server := m.mprisBridge.Server() + if server == nil { return nil } return func() tea.Msg { - _ = handler.Player.OnPlayPause() + server.StatusChanged() return nil } } @@ -387,12 +384,12 @@ func (m *Model) mprisEndedCmd() tea.Cmd { if m.mprisBridge == nil { return nil } - handler := m.mprisBridge.EventHandler() - if handler == nil || handler.Player == nil { + server := m.mprisBridge.Server() + if server == nil { return nil } return func() tea.Msg { - _ = handler.Player.OnEnded() + server.StatusChanged() return nil } } @@ -401,12 +398,12 @@ func (m *Model) mprisTitleCmd() tea.Cmd { if m.mprisBridge == nil { return nil } - handler := m.mprisBridge.EventHandler() - if handler == nil || handler.Player == nil { + server := m.mprisBridge.Server() + if server == nil { return nil } return func() tea.Msg { - _ = handler.Player.OnTitle() + server.MetadataChanged() return nil } } @@ -420,13 +417,13 @@ func (m *Model) mprisPositionCmd() tea.Cmd { return nil } m.lastMprisEmit = now - handler := m.mprisBridge.EventHandler() - if handler == nil || handler.Player == nil { + server := m.mprisBridge.Server() + if server == nil { return nil } - pos := types.Microseconds(m.playerState.Position * 1_000_000) + pos := m.playerState.Position return func() tea.Msg { - _ = handler.Player.OnSeek(pos) + server.Seeked(pos) return nil } } @@ -435,12 +432,12 @@ func (m *Model) mprisVolumeCmd() tea.Cmd { if m.mprisBridge == nil { return nil } - handler := m.mprisBridge.EventHandler() - if handler == nil || handler.Player == nil { + server := m.mprisBridge.Server() + if server == nil { return nil } return func() tea.Msg { - _ = handler.Player.OnVolume() + server.VolumeChanged() return nil } } diff --git a/scripts/release.sh b/scripts/release.sh index a3adcd8..5491bd3 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -63,6 +63,8 @@ git diff --check go mod verify # The player tests need libmpv. Without this variable they skip if it is absent. export PINE_REQUIRE_LIBMPV=1 +# The MPRIS server test needs dbus-daemon. Without this variable it skips if it is absent. +export PINE_REQUIRE_DBUS=1 go test ./... -count=1 go test -race ./... -count=1 go vet ./... From 0f4b1193ced3ea0f1aaa49134e93ec5a30abc348 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wiktor=20Zi=C4=99bka?= Date: Mon, 12 Oct 2026 14:13:05 +0200 Subject: [PATCH 2/3] ci: build release binaries The Release workflow starts when a GitHub Release is published. It builds pine and spruce with CGO_ENABLED=0 for linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, and windows/amd64, and attaches one archive for each program and each system to the release. Each archive contains the program, the LICENSE file, and the license texts of Go and of the modules that the program links. The job fails if the version in the binary is not the release tag. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 85 +++++++++++++++++++++++++++++++++++ README.md | 6 ++- docs/spruce/README.md | 8 ++++ 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c32e9bd --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,85 @@ +name: Release + +on: + release: + types: [published] + +permissions: + contents: write + +jobs: + binaries: + name: Binaries + runs-on: ubuntu-24.04 + timeout-minutes: 15 + env: + TAG: ${{ github.event.release.tag_name }} + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + + - name: Build + shell: bash + run: | + # The output is outside the checkout. Go adds "+dirty" to the + # version of a binary if the checkout contains an untracked file. + mkdir -p "$RUNNER_TEMP/dist" + for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64; do + goos=${target%/*} + goarch=${target#*/} + bin="$RUNNER_TEMP/bin/${goos}_${goarch}" + GOOS=$goos GOARCH=$goarch CGO_ENABLED=0 \ + go build -trimpath -ldflags='-s -w' -o "$bin/" . ./cmd/spruce + for program in pine spruce; do + package=. + file=$program + if [[ $program == spruce ]]; then package=./cmd/spruce; fi + if [[ $goos == windows ]]; then file=$program.exe; fi + + # Each archive contains the license texts of Go and of the + # modules that the program links. + stage="$RUNNER_TEMP/stage/${program}_${goos}_${goarch}" + mkdir -p "$stage/licenses/go" + cp "$bin/$file" LICENSE "$stage/" + cp "$(go env GOROOT)/LICENSE" "$stage/licenses/go/" + modules="$(GOOS=$goos GOARCH=$goarch CGO_ENABLED=0 go list -deps \ + -f '{{with .Module}}{{if not .Main}}{{.Path}} {{.Dir}}{{end}}{{end}}' "$package" | sort -u)" + while read -r module dir; do + dest="$stage/licenses/$module" + mkdir -p "$dest" + find "$dir" -maxdepth 1 -type f \ + \( -iname '*licen[sc]e*' -o -iname 'copying*' -o -iname 'notice*' \) -exec cp {} "$dest/" \; + if [[ -z "$(ls -A "$dest")" ]]; then + # A module without a license file states its license in its README. + find "$dir" -maxdepth 1 -type f -iname 'readme*' -exec cp {} "$dest/" \; + fi + if [[ -z "$(ls -A "$dest")" ]]; then + echo "no license text for $module" >&2 + exit 1 + fi + done <<< "$modules" + + archive="$RUNNER_TEMP/dist/${program}_${TAG}_${goos}_${goarch}" + if [[ $goos == windows ]]; then + (cd "$stage" && zip -q -r "$archive.zip" "$file" LICENSE licenses) + else + tar -czf "$archive.tar.gz" -C "$stage" "$file" LICENSE licenses + fi + done + done + + - name: Check the version + run: | + got="$("$RUNNER_TEMP/bin/linux_amd64/spruce" --version)" + if [[ $got != "spruce $TAG" ]]; then + echo "version is \"$got\", want \"spruce $TAG\"" >&2 + exit 1 + fi + + - name: Upload + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "$TAG" "$RUNNER_TEMP"/dist/* --clobber diff --git a/README.md b/README.md index a215304..55ac3a5 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ The remaining sections describe pine. For spruce, see [docs/spruce/README.md](do ## Requirements -- Go 1.26.4+ +- Go 1.26.4+ for an installation from source - libmpv, the library of [mpv](https://mpv.io/) 0.34 or later. The `mpv` program is not necessary. - Debian and Ubuntu: `sudo apt install libmpv2`. On Ubuntu 22.04 the package is `libmpv1`. - Arch: `sudo pacman -S mpv` @@ -57,6 +57,8 @@ Linux is the primary platform. macOS works with mpv from Homebrew. MPRIS needs a go install github.com/Thelost77/pine@latest ``` +Each release from v0.8.0 also has archives with a binary for Linux, macOS, and Windows. Download an archive from the [release page](https://github.com/Thelost77/pine/releases). The binary needs libmpv. See "Requirements". + ## Usage ```sh @@ -170,6 +172,8 @@ ${EDITOR:-vi} docs/releases/v0.7.0.md ./scripts/release.sh v0.7.0 ``` +When the script publishes the GitHub Release, the `Release` workflow builds the two programs for Linux, macOS, and Windows. It attaches one archive for each program and each system to the release. + ## Architecture ``` diff --git a/docs/spruce/README.md b/docs/spruce/README.md index 9aa9665..ecdab2d 100644 --- a/docs/spruce/README.md +++ b/docs/spruce/README.md @@ -47,6 +47,11 @@ go install github.com/Thelost77/pine/cmd/spruce@latest Ensure the Go binary directory, normally `~/go/bin`, is in `PATH`. +Each release from v0.8.0 also has archives with a binary for Linux, macOS, and +Windows. Download an archive from the +[release page](https://github.com/Thelost77/pine/releases). The binary needs +libmpv. See "Requirements". + To build the current checkout: ```sh @@ -193,6 +198,9 @@ The script verifies the repository, creates and pushes the exact annotated tag, and publishes a GitHub Release. It can safely retry release creation after a partial failure. +The `Release` workflow then builds the two programs for Linux, macOS, and +Windows. It attaches the archives to the GitHub Release. + ## Related project [pine](../../README.md) is a terminal client for Audiobookshelf. It is in the From 0abea0f82af9ce7243a0aa826cd475b13183897b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wiktor=20Zi=C4=99bka?= Date: Mon, 12 Oct 2026 14:13:05 +0200 Subject: [PATCH 3/3] chore(release): prepare v0.8.0 Add the release notes for v0.8.0, the release with binaries and with the MPRIS server of our own. Name the new version in the release steps of the two READMEs. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- docs/releases/v0.8.0.md | 91 +++++++++++++++++++++++++++++++++++++++++ docs/spruce/README.md | 2 +- 3 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 docs/releases/v0.8.0.md diff --git a/README.md b/README.md index 55ac3a5..aec4042 100644 --- a/README.md +++ b/README.md @@ -168,8 +168,8 @@ One tag releases pine and spruce together. Write the release notes for the two p Commit the notes and push them to `main`. Then use the release script. ```sh -${EDITOR:-vi} docs/releases/v0.7.0.md -./scripts/release.sh v0.7.0 +${EDITOR:-vi} docs/releases/v0.8.0.md +./scripts/release.sh v0.8.0 ``` When the script publishes the GitHub Release, the `Release` workflow builds the two programs for Linux, macOS, and Windows. It attaches one archive for each program and each system to the release. diff --git a/docs/releases/v0.8.0.md b/docs/releases/v0.8.0.md new file mode 100644 index 0000000..8f18043 --- /dev/null +++ b/docs/releases/v0.8.0.md @@ -0,0 +1,91 @@ +# pine and spruce v0.8.0 + +Each release now has binaries for Linux, macOS, and Windows. pine and spruce +also have an MPRIS server of their own. It replaces a module with a GPL-3.0 +license. All the code in the two programs now has a permissive license. + +## Installation + +```sh +go install github.com/Thelost77/pine@v0.8.0 +go install github.com/Thelost77/pine/cmd/spruce@v0.8.0 +``` + +As an alternative, download an archive from the release page. See "Binaries". + +## Binaries + +The release has one archive for each program and each system. The name of an +archive is `_v0.8.0__`: + +| System | Architecture | File type | +|--------|--------------|-----------| +| `linux` | `amd64`, `arm64` | `.tar.gz` | +| `darwin` (macOS) | `amd64`, `arm64` | `.tar.gz` | +| `windows` | `amd64` | `.zip` | + +Each archive contains the program, the `LICENSE` file of pine, and a `licenses` +directory. That directory has the license texts of Go and of the modules that +the program links. + +- A binary needs libmpv. See "Requirements". +- On Windows, put `libmpv-2.dll` in the directory of the program. The archive + does not contain it. +- The binaries are not signed. +- A GitHub Actions workflow builds the binaries on Linux with `CGO_ENABLED=0`. + The binaries for `linux_arm64`, macOS, and Windows have not run on a test + machine. + +## Licenses + +pine and spruce have the MIT license. From v0.8.0, each module that the two +programs link has an MIT, BSD, or Apache-2.0 license. + +Versions v0.2.0 to v0.7.0 link the module +`github.com/quarckster/go-mpris-server`, which has the GPL-3.0 license. spruce +v0.1.0 and v0.2.0 from its old repository link it too. If you distribute a +binary of one of these versions, the GPL-3.0 applies to that binary. These +versions have no binaries on the release page. + +## pine and spruce + +### What's New + +* **Binaries on the release page:** an installation does not need Go. +* **An MPRIS server of our own:** the server is written from the MPRIS 2.2 + specification. It uses `godbus/dbus` and no other module. + +### Reliability and Correctness + +* A D-Bus client that set `Volume` to a value of a wrong type stopped the + program. The server now returns an error. +* The MPRIS metadata contains only the fields that have a value. Before, it + contained 22 fields, and most of them were empty. +* The introspection data shows only the interfaces that the programs have. + Before, it also showed `TrackList` and `Playlists`. +* The `PropertiesChanged` signal does not contain `Position` or `CanControl`. + The MPRIS specification excludes them from that signal. + +## Platforms + +* **Linux:** pine was tested with the buttons of Bluetooth headphones, through + BlueZ 5.72 and `mpris-proxy`. +* BlueZ gives the headphone buttons to the first MPRIS player on the session + bus. If a browser plays media before pine or spruce starts, the buttons + control the browser. This is the behavior of BlueZ. pine and spruce cannot + change it. + +## Development + +* The `Release` workflow builds the archives when a GitHub Release is + published. +* A new test starts the MPRIS server on a private D-Bus session bus and checks + it as an MPRIS client does. With `PINE_REQUIRE_DBUS=1`, the test fails if + `dbus-daemon` is absent. Without the variable, the test skips. + +## Requirements + +- Go 1.26.4 or newer, for an installation from source +- libmpv from [mpv](https://mpv.io/) 0.34 or later +- pine: an [Audiobookshelf](https://www.audiobookshelf.org/) server +- spruce: a [Jellyfin](https://jellyfin.org/) account with a music library diff --git a/docs/spruce/README.md b/docs/spruce/README.md index ecdab2d..de1df9d 100644 --- a/docs/spruce/README.md +++ b/docs/spruce/README.md @@ -191,7 +191,7 @@ Commit the notes and all release changes. Push them to `main`. Then use the release script: ```sh -./scripts/release.sh v0.7.0 +./scripts/release.sh v0.8.0 ``` The script verifies the repository, creates and pushes the exact annotated tag,