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/.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/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/README.md b/README.md index a215304..aec4042 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 @@ -166,10 +168,12 @@ 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. + ## Architecture ``` 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 9aa9665..de1df9d 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 @@ -186,13 +191,16 @@ 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, 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 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 ./...