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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Reject negative, malformed, and overflowing seek positions before playback; preserve large valid millisecond values and include pagination offsets in top-track ranks

- Keep cookie and Connect cache paths inside their profile directories, preventing profile names from overwriting or clearing another profile's credentials

## 0.13.0 - 2026-09-13
Expand Down
2 changes: 2 additions & 0 deletions docs/output.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ Rows have no header. Field order is stable:
| Top track | rank, then the track columns above |
| History item | played-at timestamp, then the track columns above |

Top-track ranks are one-based positions in the full result set: `user top-tracks --offset 20` starts at rank `21` in both human and plain output.

`queue show --plain` prints upcoming tracks only. JSON includes the current item separately. Successful mutation commands generally print `ok`; commands with additional results are described in their command guides.

Artists are joined with a comma and space. Text fields currently pass tabs and newlines through unchanged; use JSON when arbitrary metadata must round-trip safely.
Expand Down
2 changes: 2 additions & 0 deletions docs/playback.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ spogo seek 1:30 # mm:ss
spogo seek 0 # back to start
```

Positions must be non-negative. In `mm:ss` form, seconds must be `0` through `59`; positions that exceed the CLI's integer range are rejected before playback. Large valid values are converted directly to milliseconds without duration overflow.

## volume

```bash
Expand Down
6 changes: 3 additions & 3 deletions internal/cli/history.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ func (cmd *UserTopTracksCmd) Run(ctx *app.Context) error {
if err != nil {
return err
}
plain, human := renderTopTracks(ctx.Output, res.Items)
plain, human := renderTopTracks(ctx.Output, res.Items, res.Offset)
payload := map[string]any{
"total": res.Total,
"limit": res.Limit,
Expand Down Expand Up @@ -192,11 +192,11 @@ func parseRFC3339Milli(s string) (int64, error) {
return t.UnixMilli(), nil
}

func renderTopTracks(w *output.Writer, items []spotify.Item) (plain []string, human []string) {
func renderTopTracks(w *output.Writer, items []spotify.Item, offset int) (plain []string, human []string) {
plain = make([]string, 0, len(items))
human = make([]string, 0, len(items))
for i, item := range items {
rank := i + 1
rank := offset + i + 1
plain = append(plain, fmt.Sprintf("%d\ttrack\t%s\t%s\t%s\t%s\t%s", rank, item.ID, item.Name, strings.Join(item.Artists, ", "), item.Album, item.URI))
human = append(human, fmt.Sprintf("%d. %s", rank, humanItemLine(w, item.Name, strings.Join(item.Artists, ", "), item.Album)))
}
Expand Down
90 changes: 90 additions & 0 deletions internal/cli/numeric_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package cli

import (
"context"
"fmt"
"math"
"strconv"
"strings"
"testing"

"github.com/steipete/spogo/internal/output"
"github.com/steipete/spogo/internal/spotify"
"github.com/steipete/spogo/internal/testutil"
)

func TestSeekRejectsInvalidPositionBeforePlayback(t *testing.T) {
for _, input := range []string{"-1", "-1:30", "1:-30", "0:60", "1:99", fmt.Sprintf("%d:00", math.MaxInt/60000+1), strconv.Itoa(math.MaxInt) + "0"} {
t.Run(input, func(t *testing.T) {
ctx, out, _ := testutil.NewTestContext(t, output.FormatJSON)
ctx.SetSpotify(&testutil.SpotifyMock{SeekFn: func(context.Context, int) error {
t.Error("invalid input reached playback")
return nil
}})
if err := (&SeekCmd{Position: input}).Run(ctx); err == nil {
t.Fatal("invalid position reported success")
}
if out.Len() != 0 {
t.Fatalf("unexpected success output: %s", out)
}
})
}
}

func TestParsePositionWithoutDurationOverflow(t *testing.T) {
cases := []struct {
input string
want int
}{
{"0", 0},
{"0:00", 0},
{"2:03", 123000},
{" 90000 ", 90000},
{strconv.Itoa(math.MaxInt), math.MaxInt},
{fmt.Sprintf("%d:%02d", math.MaxInt/60000, (math.MaxInt%60000)/1000), math.MaxInt / 1000 * 1000},
}
if strconv.IntSize == 64 {
large, err := strconv.Atoi("9223372080000")
if err != nil {
t.Fatal(err)
}
cases = append(cases, struct {
input string
want int
}{"153722868:00", large})
if got := humanDuration(large); got != "2562047h48m00s" {
t.Errorf("duration = %q", got)
}
}
for _, tc := range cases {
if got, err := parsePosition(tc.input); err != nil || got != tc.want {
t.Errorf("parsePosition(%q) = %d, %v; want %d", tc.input, got, err, tc.want)
}
}
}

func TestTopTrackRanksIncludePageOffset(t *testing.T) {
for _, format := range []output.Format{output.FormatPlain, output.FormatHuman} {
t.Run(fmt.Sprint(format), func(t *testing.T) {
ctx, out, _ := testutil.NewTestContext(t, format)
ctx.SetSpotify(&testutil.SpotifyMock{GetUsersTopTracksFn: func(_ context.Context, _ string, limit, offset int) (spotify.TopTracksResult, error) {
if limit != 2 || offset != 20 {
t.Fatalf("request: limit=%d offset=%d", limit, offset)
}
return spotify.TopTracksResult{Total: 40, Limit: 2, Offset: 20, Items: []spotify.Item{{ID: "t21", Type: "track", Name: "Twenty-one"}, {ID: "t22", Type: "track", Name: "Twenty-two"}}}, nil
}})
if err := (&UserTopTracksCmd{Period: "long_term", Limit: 2, Offset: 20}).Run(ctx); err != nil {
t.Fatal(err)
}
for _, rank := range []string{"21", "22"} {
prefix := rank + "\ttrack\t"
if format == output.FormatHuman {
prefix = rank + ". "
}
if !strings.Contains(out.String(), prefix) {
t.Errorf("missing rank %s: %s", rank, out)
}
}
})
}
}
21 changes: 16 additions & 5 deletions internal/cli/playback.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import (
"context"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"

"github.com/steipete/spogo/internal/app"
"github.com/steipete/spogo/internal/spotify"
Expand Down Expand Up @@ -145,11 +145,11 @@ func (cmd *PrevCmd) Run(ctx *app.Context) error {
}

func (cmd *SeekCmd) Run(ctx *app.Context) error {
client, cmdCtx, err := spotifyClient(ctx)
position, err := parsePosition(cmd.Position)
if err != nil {
return err
}
position, err := parsePosition(cmd.Position)
client, cmdCtx, err := spotifyClient(ctx)
if err != nil {
return err
}
Expand Down Expand Up @@ -235,13 +235,24 @@ func parsePosition(input string) (int, error) {
if err != nil {
return 0, err
}
d := time.Duration(min)*time.Minute + time.Duration(sec)*time.Second
return int(d / time.Millisecond), nil
if min < 0 || sec < 0 {
return 0, fmt.Errorf("position must be non-negative")
}
if sec >= 60 {
return 0, fmt.Errorf("seconds must be between 0 and 59")
}
if min > (math.MaxInt-sec*1000)/60000 {
return 0, fmt.Errorf("position out of range")
}
return min*60000 + sec*1000, nil
}
ms, err := strconv.Atoi(input)
if err != nil {
return 0, err
}
if ms < 0 {
return 0, fmt.Errorf("position must be non-negative")
}
return ms, nil
}

Expand Down
9 changes: 4 additions & 5 deletions internal/cli/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"fmt"
"strconv"
"strings"
"time"

"github.com/steipete/spogo/internal/output"
"github.com/steipete/spogo/internal/spotify"
Expand Down Expand Up @@ -118,10 +117,10 @@ func humanDuration(ms int) string {
if ms <= 0 {
return "0s"
}
d := time.Duration(ms) * time.Millisecond
h := int(d.Hours())
m := int(d.Minutes()) % 60
s := int(d.Seconds()) % 60
seconds := ms / 1000
h := seconds / 3600
m := seconds / 60 % 60
s := seconds % 60
if h > 0 {
return fmt.Sprintf("%dh%02dm%02ds", h, m, s)
}
Expand Down
Loading