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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ Styles from theme config. Format helpers (timestamps, durations). `RunProgram` r
- Each launch makes a new mpv handle, which plays at the speed 1.0. `handlePlayerReady` sets the speed of the model in the new player.
- The listening time (`timeListened`) is the time of the clock between two position ticks of a player that plays (`trackListeningTime`). It is not the change of the position: at the speed 2.0, the position moves two seconds in each second. A player that is paused or idle does not play. mpv reports no pause while it waits for data, so a position tick also reads the mpv property `core-idle` (`GetIdle`, `PositionMsg.Idle`). A test moves the clock with `Model.now`.
- pine finds the end of a track through `errors.Is(err, player.ErrClosed)` on a position tick. spruce uses `PlayerEndMsg`, and a tick with `ErrClosed` can arrive before it. Do not match the text of a player error.
- Enter on a bookmark (`handleSeekToBookmark`) seeks the player only if the book or the episode of the bookmark plays. If not, it stops the playback and starts that book or that episode at the time of the bookmark.
- A bookmark of ABS has no episode ID. `handleAddBookmark` puts the episode title and `episodeTitleSeparator` before the title of a podcast bookmark, and `bookmarkEpisode` finds the episode from that title. The bookmarks on a server have that format, so do not change it.

## Testing

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,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.8.2.md
./scripts/release.sh v0.8.2
${EDITOR:-vi} docs/releases/v0.8.3.md
./scripts/release.sh v0.8.3
```

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.
Expand Down
98 changes: 98 additions & 0 deletions docs/releases/v0.8.3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# pine and spruce v0.8.3

This release corrects the Enter key on a bookmark in pine. Enter now plays the
book or the podcast episode of the bookmark. spruce has no change.

## 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.3_<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`.
No binary of this release has run on a test machine.

## pine

### Reliability and Correctness

Bookmarks:

* **Enter on a bookmark moved the item that played, not the item of the
bookmark.** While book A played, Enter on a bookmark of book B moved book A
to the time of the bookmark. pine now stops book A, and then starts book B
at the time of the bookmark.
* **Enter on a podcast bookmark did not use the episode of the bookmark.** An
Audiobookshelf bookmark has no episode. While an item played, pine moved
that item to the time of the bookmark. While no item played, pine asked the
server to play the podcast with no episode. pine now finds the episode from
the title of the bookmark, and starts that episode at the time of the
bookmark.

### Changes of Behavior

* Enter on a bookmark seeks the player only if the book or the episode of the
bookmark plays. If not, Enter stops the current playback, and then starts
the book or the episode of the bookmark at the time of the bookmark.
* pine finds the episode of a podcast bookmark from the title of the bookmark.
pine puts the episode title and ` — ` before the title of a podcast bookmark
that it makes. Each release of pine does this. If you edit the title of a
podcast bookmark, keep that part.
* Some podcast bookmarks have no episode title at the start of their title:
the user changed the title, or a different program made the bookmark. For
such a bookmark, Enter seeks the player if the podcast of the bookmark
plays. If not, pine shows the error `cannot find the episode of this
bookmark`, and the playback does not change.
* If two episodes of a podcast have the same title, pine uses the first of
them in its episode list.

## spruce

spruce has no change in this release. The changed packages, `internal/app` and
`internal/screens/detail`, are part of pine only.

## Development

* Each correction has a test that failed before the correction. The tests use
a fake Audiobookshelf server and the mock player of the app tests.
* No real Audiobookshelf server ran for these corrections, and pine did not
run in a terminal. The tests send messages to the model and read its state.
* The statement that an Audiobookshelf bookmark has no episode is from the
bookmark type of pine, in `internal/abs/types.go`. The source of
Audiobookshelf was not read for this release.
* The answer of Audiobookshelf to a play request for a podcast with no episode
is not known. pine does not send that request for a bookmark now.
* The notes of v0.8.2 named two tests that failed at times in CI on Windows:
`TestE2E_LoginFailure` and `TestE2E_CacheSurvivesRestart`. The two tests now
wait up to 15 seconds for the commands that can be slow. Each other command
of a test has a wait of 200 ms, as before. CI ran two times on that change,
and the two runs passed. Two runs do not show that the fault is gone.

## 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
2 changes: 1 addition & 1 deletion docs/spruce/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.8.2
./scripts/release.sh v0.8.3
```

The script verifies the repository, creates and pushes the exact annotated tag,
Expand Down
76 changes: 67 additions & 9 deletions internal/app/bookmarks.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package app
import (
"context"
"fmt"
"strings"
"time"

"github.com/Thelost77/pine/internal/abs"
Expand Down Expand Up @@ -37,7 +38,7 @@ func (m Model) handleAddBookmark(msg detail.AddBookmarkCmd) (Model, tea.Cmd) {
currentTime := m.player.Position
title := fmt.Sprintf("Bookmark at %s", ui.FormatTimestamp(currentTime))
if m.episodeID != "" && m.player.Title != "" {
title = fmt.Sprintf("%s — %s", m.player.Title, title)
title = m.player.Title + episodeTitleSeparator + title
}
logger.Info("creating bookmark", "itemID", itemID, "time", currentTime, "title", title)
existing := m.detail.Bookmarks()
Expand All @@ -58,16 +59,73 @@ func (m Model) handleAddBookmark(msg detail.AddBookmarkCmd) (Model, tea.Cmd) {
}
}

// handleSeekToBookmark seeks the player to a bookmark's timestamp (book-global).
func (m Model) handleSeekToBookmark(msg detail.SeekToBookmarkCmd) (Model, tea.Cmd) {
if !m.isPlaying() {
if m.client == nil || msg.Item.ID == "" {
return m, nil
// episodeTitleSeparator is between the episode title and the bookmark title
// in the title of a bookmark of a podcast episode.
const episodeTitleSeparator = " — "

// bookmarkEpisode finds the episode of a podcast bookmark. A bookmark of ABS
// has no episode ID, so handleAddBookmark puts the episode title before the
// bookmark title. If more than one episode title is before the bookmark title,
// the longest is the episode.
func bookmarkEpisode(title string, episodes []abs.PodcastEpisode) (abs.PodcastEpisode, bool) {
var episode abs.PodcastEpisode
found := false
for _, ep := range episodes {
if ep.Title == "" || !strings.HasPrefix(title, ep.Title+episodeTitleSeparator) {
continue
}
m.setSeriesContext(msg.Item)
return m, m.startPlaybackAtBookPositionCmd(msg.Item, msg.Time)
if !found || len(ep.Title) > len(episode.Title) {
episode, found = ep, true
}
}
return episode, found
}

// handleSeekToBookmark moves the playback to a bookmark's timestamp
// (book-global). If the book or the episode of the bookmark plays, it seeks
// the player. If not, it starts the book or the episode at that timestamp.
func (m Model) handleSeekToBookmark(msg detail.SeekToBookmarkCmd) (Model, tea.Cmd) {
isPodcast := msg.Item.MediaType == "podcast"
var episode abs.PodcastEpisode
hasEpisode := false
if isPodcast {
episode, hasEpisode = bookmarkEpisode(msg.Title, m.detail.Episodes())
}

// The title of a podcast bookmark can give no episode, for example after
// an edit of the title. Then a bookmark of the podcast that plays seeks
// the episode that plays.
if m.isPlaying() && m.itemID == msg.Item.ID && (!hasEpisode || m.episodeID == episode.ID) {
return m.seekToBookGlobalPosition(msg.Time)
}
if m.client == nil || msg.Item.ID == "" {
return m, nil
}
if isPodcast && !hasEpisode {
logger.Warn("bookmark seek skipped: episode not found", "itemID", msg.Item.ID, "title", msg.Title)
cmd := m.err.SetError(fmt.Errorf("cannot find the episode of this bookmark"))
m.propagateSize()
return m, cmd
}

// Different item or episode playing → stop it first
var closeCmd, stopCmd tea.Cmd
if m.isPlaying() {
logger.Info("switching playback", "from", m.itemID+"/"+m.episodeID, "to", msg.Item.ID+"/"+episode.ID)
m, closeCmd, stopCmd = m.stopPlaybackCmds()
}

m.setSeriesContext(msg.Item)
var startCmd tea.Cmd
if hasEpisode {
startCmd = m.startEpisodePlaybackAtPositionCmd(msg.Item, episode, msg.Time)
} else {
startCmd = m.startPlaybackAtBookPositionCmd(msg.Item, msg.Time)
}
if stopCmd != nil {
return m, tea.Batch(stopCmd, closeThen(closeCmd, startCmd))
}
return m.seekToBookGlobalPosition(msg.Time)
return m, startCmd
}

// handleDeleteBookmark deletes a bookmark and removes it from the local list.
Expand Down
29 changes: 29 additions & 0 deletions internal/app/bookmarks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -288,3 +288,32 @@ func TestHandleSeekToBookmarkStartsPlaybackWhenStopped(t *testing.T) {
t.Errorf("expected no progress patch before starting playback, got %v", progressBody)
}
}

func TestBookmarkEpisode(t *testing.T) {
episodes := []abs.PodcastEpisode{
{ID: "ep-1", Title: "Pilot"},
{ID: "ep-2", Title: "Pilot — Part 2"},
{ID: "ep-3", Title: ""},
}

tests := []struct {
name string
title string
wantID string
wantOK bool
}{
{"title of handleAddBookmark", "Pilot — Bookmark at 2:00", "ep-1", true},
{"longest episode title", "Pilot — Part 2 — Bookmark at 2:00", "ep-2", true},
{"no episode title", "Bookmark at 2:00", "", false},
{"episode title with no separator", "Pilot", "", false},
{"empty episode title", " — Bookmark at 2:00", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
episode, ok := bookmarkEpisode(tt.title, episodes)
if ok != tt.wantOK || episode.ID != tt.wantID {
t.Errorf("bookmarkEpisode(%q) = %q, %v, want %q, %v", tt.title, episode.ID, ok, tt.wantID, tt.wantOK)
}
})
}
}
Loading
Loading