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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
85 changes: 85 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ go vet ./... # type-check
1. Use `gofmt -l .`. Make sure that it shows no file. Use `gofmt -w <file>` 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

Expand Down Expand Up @@ -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/)

Expand Down Expand Up @@ -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.
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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
Expand Down Expand Up @@ -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

```
Expand Down
91 changes: 91 additions & 0 deletions docs/releases/v0.8.0.md
Original file line number Diff line number Diff line change
@@ -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/[email protected]
go install github.com/Thelost77/pine/cmd/[email protected]
```

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 `<program>_v0.8.0_<system>_<architecture>`:

| 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
10 changes: 9 additions & 1 deletion docs/spruce/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
27 changes: 13 additions & 14 deletions internal/app/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
Expand All @@ -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
}
}
Expand All @@ -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
}
}
Expand All @@ -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
}
}
Expand All @@ -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
}
}
Expand All @@ -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
}
}
Expand Down
Loading
Loading