Skip to content

Commit 9c9d02b

Browse files
authored
feat: wake speaker from standby when playing from queue (#30)
* feat: add seek command for jumping to position in tracks Add new 'kefw2 seek <position>' command that allows jumping to a specific position in the current track. Supports flexible time input formats: - hh:mm:ss (e.g., 1:23:45) - mm:ss (e.g., 5:30) - seconds (e.g., 90) Library changes: - Add SeekTo(ctx, positionMS) method for programmatic seeking - Add setActivateMap helper for player control commands with mixed types - Add int64 type support to setTypedValue for i64_ values The seek uses the player:player/control endpoint with the seekTime control, matching the KEF Connect app's behavior. * fix: improve airable browsing with multi-redirect support and caching - GetRadioMenu/GetPodcastMenu now follow up to 5 redirects - Fixed podcast entry point from ui:/airablefeeds to airable:linkService_airable.feeds - GetRows() now uses cache when available for better performance * Update year * feat: wake speaker from standby when playing from queue PlayOrResumeFromQueue now detects standby and automatically switches to WiFi source, polls until the speaker is awake, then proceeds with normal playback logic. The CLI play command allows standby through instead of refusing, and prints a notice when the speaker was woken. Also includes: AlbumsForArtist helper, goreleaser cask migration, Airable redirect handling fixes, test improvements, and minor cleanups.
1 parent 19cd10d commit 9c9d02b

13 files changed

Lines changed: 350 additions & 64 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,6 @@ fyne-cross
66
dist/
77
completions/
88
research
9+
go.work
10+
go.work.sum
911

.goreleaser.yaml

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,30 +64,35 @@ archives:
6464
- LICENSE
6565
- completions/*
6666

67-
brews:
67+
homebrew_casks:
6868
- name: kefw2
69-
goarm: 7
7069
commit_author:
7170
name: Jens Hilligsøe
7271
email: github@hilli.dk
73-
commit_msg_template: "Brew formula update for {{ .ProjectName }} version {{ .Tag }}"
72+
commit_msg_template: "Brew cask update for {{ .ProjectName }} version {{ .Tag }}"
7473
homepage: "https://github.com/hilli/go-kef-w2"
75-
description: "Command for handling KEF W2 platform speakers (LSX Wireless II (LT)/LS50 Wireless II/LS60 Wireless)"
76-
license: "MIT"
77-
url_template: "https://github.com/hilli/go-kef-w2/releases/download/{{ .Tag }}/{{ .ArtifactName }}"
74+
description: "CLI for controlling KEF W2 platform speakers"
75+
url:
76+
template: "https://github.com/hilli/go-kef-w2/releases/download/{{ .Tag }}/{{ .ArtifactName }}"
77+
verified: "github.com/hilli/go-kef-w2/"
7878
skip_upload: false
79-
directory: Formula
79+
completions:
80+
bash: completions/kefw2.bash
81+
zsh: completions/kefw2.zsh
82+
fish: completions/kefw2.fish
83+
hooks:
84+
post:
85+
install: |
86+
if OS.mac?
87+
system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/kefw2"]
88+
end
8089
repository:
8190
owner: hilli
8291
name: homebrew-tap
8392
branch: release-go-kef-w2-{{.Tag}}
8493
pull_request:
8594
enabled: true
8695
draft: false
87-
extra_install: |-
88-
bash_completion.install "completions/kefw2.bash" => "kefw2"
89-
zsh_completion.install "completions/kefw2.zsh" => "_kefw2"
90-
fish_completion.install "completions/kefw2.fish"
9196

9297
scoops:
9398
- repository:

CHANGELOG.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.2.7] - 2026-02-11
11+
12+
### Added
13+
14+
- **Standby wake on play**: `PlayOrResumeFromQueue` now automatically switches to WiFi and waits for the speaker to wake when called from standby, then starts playback from the queue
15+
- **Library: `WokeFromStandby` field on `PlayResult`**: Callers can check whether a standby wake occurred during playback start
16+
- **Library: `AlbumsForArtist()` helper**: New function and `ArtistAlbum` type for extracting unique albums from artist search results
17+
18+
### Changed
19+
20+
- **`play` command wakes from standby**: The CLI play command now wakes the speaker from standby instead of refusing; still refuses on non-streamable physical sources (optical, coaxial, etc.)
21+
- **Goreleaser: Homebrew cask**: Switched from Homebrew formula to Homebrew cask with shell completion installation and macOS quarantine removal
22+
- **Renamed `min` to `mins` in seek**: Avoids shadowing the Go 1.21+ `min` builtin
23+
24+
### Fixed
25+
26+
- **PlayerTrackRoles documentation**: Corrected `Path` and `ID` field comments — these are internal item IDs, not display indices
27+
- **Airable redirect handling**: Radio and podcast menu endpoints now properly follow redirects and return rows from the redirected path
28+
- **Import ordering**: Fixed import grouping in `cache.go`
29+
1030
## [0.2.6] - 2026-02-06
1131

1232
### Added
@@ -417,7 +437,8 @@ Implemented by: `Source`, `SpeakerStatus`, `CableMode`
417437

418438
7. **Update player ID field access**: If you access `playId.SystemMemberId`, change it to `playId.SystemMemberID`.
419439

420-
[Unreleased]: https://github.com/hilli/go-kef-w2/compare/v0.2.6...HEAD
440+
[Unreleased]: https://github.com/hilli/go-kef-w2/compare/v0.2.7...HEAD
441+
[0.2.7]: https://github.com/hilli/go-kef-w2/compare/v0.2.6...v0.2.7
421442
[0.2.6]: https://github.com/hilli/go-kef-w2/compare/v0.2.5...v0.2.6
422443
[0.2.5]: https://github.com/hilli/go-kef-w2/compare/v0.2.4...v0.2.5
423444
[0.2.4]: https://github.com/hilli/go-kef-w2/compare/v0.2.3...v0.2.4

LICENSE

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright (c) 2023-2024 Jens Hilligsøe, https://github.com/hilli
3+
Copyright (c) 2023-2026 Jens Hilligsøe, https://github.com/hilli
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21-
SOFTWARE.
21+
SOFTWARE.

cmd/kefw2/cmd/cache.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,10 @@ import (
2929
"sync"
3030
"time"
3131

32-
"github.com/hilli/go-kef-w2/kefw2"
3332
"github.com/spf13/cobra"
3433
"github.com/spf13/viper"
34+
35+
"github.com/hilli/go-kef-w2/kefw2"
3536
)
3637

3738
// CachedItem represents a cached content item for completion.

cmd/kefw2/cmd/play.go

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,28 +22,59 @@ THE SOFTWARE.
2222
package cmd
2323

2424
import (
25+
"fmt"
26+
2527
"github.com/spf13/cobra"
28+
29+
"github.com/hilli/go-kef-w2/kefw2"
2630
)
2731

28-
// muteCmd toggles the mute state of the speakers.
32+
// playCmd resumes or starts playback on the speaker.
2933
var playCmd = &cobra.Command{
3034
Use: "play",
31-
Short: "Resume playback when on WiFi/BT source if paused",
32-
Long: `Resume playback when on WiFi/BT source if paused`,
33-
Args: cobra.MaximumNArgs(0),
35+
Short: "Resume or start playback",
36+
Long: `Resume or start playback.
37+
38+
If the speaker is in standby, it will be woken up by switching to WiFi
39+
before starting playback. If paused, playback is resumed. If stopped and
40+
the queue has tracks, playback starts from the top of the queue (or a
41+
random track if shuffle is enabled). If the queue is empty, a message is
42+
shown.`,
43+
Args: cobra.MaximumNArgs(0),
3444
Run: func(cmd *cobra.Command, _ []string) {
3545
ctx := cmd.Context()
36-
canControlPlayback, err := currentSpeaker.CanControlPlayback(ctx)
46+
47+
// Check current source - refuse if on a non-streamable physical input
48+
// (optical, coaxial, etc.) but allow standby since PlayOrResumeFromQueue
49+
// will wake the speaker by switching to WiFi.
50+
source, err := currentSpeaker.Source(ctx)
3751
exitOnError(err, "Can't query source")
38-
if !canControlPlayback {
39-
headerPrinter.Println("Can only play on WiFi/BT source.")
52+
if source != kefw2.SourceWiFi && source != kefw2.SourceBluetooth && source != kefw2.SourceStandby {
53+
headerPrinter.Printf("Can only play on WiFi/BT source (current: %s).\n", source)
4054
return
4155
}
42-
isPlaying, err := currentSpeaker.IsPlaying(ctx)
43-
exitOnError(err, "Can't check playback state")
44-
if !isPlaying {
45-
err = currentSpeaker.PlayPause(ctx)
46-
exitOnError(err, "Can't resume playback")
56+
57+
client := kefw2.NewAirableClient(currentSpeaker)
58+
result, err := client.PlayOrResumeFromQueue(ctx)
59+
exitOnError(err, "Can't play")
60+
61+
switch result.Action {
62+
case kefw2.PlayActionStartedFromQueue:
63+
if result.WokeFromStandby {
64+
headerPrinter.Print("Woke speaker from standby. ")
65+
}
66+
if result.Shuffled {
67+
headerPrinter.Print("Shuffling queue, playing: ")
68+
} else {
69+
headerPrinter.Print("Playing from queue: ")
70+
}
71+
contentPrinter.Printf("%s", result.Track.Title)
72+
if result.Track.MediaData != nil && result.Track.MediaData.MetaData.Artist != "" {
73+
contentPrinter.Printf(" - %s", result.Track.MediaData.MetaData.Artist)
74+
}
75+
fmt.Println()
76+
case kefw2.PlayActionNothingToPlay:
77+
headerPrinter.Println("Nothing to play - queue is empty.")
4778
}
4879
},
4980
}

cmd/kefw2/cmd/seek.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -113,29 +113,29 @@ func parseTimePosition(s string) (int64, error) {
113113

114114
case 2:
115115
// mm:ss
116-
min, err := strconv.ParseInt(parts[0], 10, 64)
116+
mins, err := strconv.ParseInt(parts[0], 10, 64)
117117
if err != nil {
118118
return 0, fmt.Errorf("invalid minutes: %s", parts[0])
119119
}
120120
sec, err := strconv.ParseInt(parts[1], 10, 64)
121121
if err != nil {
122122
return 0, fmt.Errorf("invalid seconds: %s", parts[1])
123123
}
124-
if min < 0 {
124+
if mins < 0 {
125125
return 0, fmt.Errorf("minutes cannot be negative")
126126
}
127127
if sec < 0 || sec >= 60 {
128128
return 0, fmt.Errorf("seconds must be 0-59")
129129
}
130-
return (min*60 + sec) * 1000, nil
130+
return (mins*60 + sec) * 1000, nil
131131

132132
case 3:
133133
// hh:mm:ss
134134
hours, err := strconv.ParseInt(parts[0], 10, 64)
135135
if err != nil {
136136
return 0, fmt.Errorf("invalid hours: %s", parts[0])
137137
}
138-
min, err := strconv.ParseInt(parts[1], 10, 64)
138+
mins, err := strconv.ParseInt(parts[1], 10, 64)
139139
if err != nil {
140140
return 0, fmt.Errorf("invalid minutes: %s", parts[1])
141141
}
@@ -146,13 +146,13 @@ func parseTimePosition(s string) (int64, error) {
146146
if hours < 0 {
147147
return 0, fmt.Errorf("hours cannot be negative")
148148
}
149-
if min < 0 || min >= 60 {
149+
if mins < 0 || mins >= 60 {
150150
return 0, fmt.Errorf("minutes must be 0-59")
151151
}
152152
if sec < 0 || sec >= 60 {
153153
return 0, fmt.Errorf("seconds must be 0-59")
154154
}
155-
return (hours*3600 + min*60 + sec) * 1000, nil
155+
return (hours*3600 + mins*60 + sec) * 1000, nil
156156

157157
default:
158158
return 0, fmt.Errorf("invalid time format: %s (use hh:mm:ss, mm:ss, or seconds)", s)

kefw2/airable_podcast.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ func (a *AirableClient) PlayPodcastEpisode(episode *ContentItem) error {
214214
// We try to find the container path from:
215215
// 1. The contentPlayContextPath metadata (if it points to a container)
216216
// 2. Cached/known podcast feed paths
217-
// 3. Pattern matching on the episode path
217+
// 3. Pattern matching on the episode path.
218218
func (a *AirableClient) getEpisodesContainerPath(episode *ContentItem) string {
219219
// The episode path format is: airable:https://xxx.airable.io/id/airable/feed.episode/EPISODE_ID
220220
// We can't directly derive the podcast feed ID from this.

kefw2/airable_test.go

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -47,27 +47,45 @@ func TestAirableClient_GetRows(t *testing.T) {
4747
}
4848

4949
func TestAirableClient_GetRadioMenu(t *testing.T) {
50-
// Mock server to simulate the API
50+
// Mock server to simulate the API with redirect handling
5151
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
5252
if r.URL.Path == "/api/getRows" {
53-
response := RowsResponse{
54-
RowsCount: 0,
55-
RowsRedirect: "airable:https://mock.airable.io/airable/radios",
53+
path := r.URL.Query().Get("path")
54+
if path == "airable:https://mock.airable.io/airable/radios" {
55+
// Second request after redirect - return actual rows
56+
response := RowsResponse{
57+
RowsCount: 1,
58+
Rows: []ContentItem{
59+
{Title: "Popular", Type: "container", Path: "airable:https://mock.airable.io/airable/radios/popular"},
60+
},
61+
}
62+
_ = json.NewEncoder(w).Encode(response)
63+
} else {
64+
// Initial request - return redirect
65+
response := RowsResponse{
66+
RowsCount: 0,
67+
RowsRedirect: "airable:https://mock.airable.io/airable/radios",
68+
}
69+
_ = json.NewEncoder(w).Encode(response)
5670
}
57-
_ = json.NewEncoder(w).Encode(response)
5871
}
5972
}))
6073
defer mockServer.Close()
6174

6275
// Initialize AirableClient with the mock server
6376
client := NewAirableClient(&KEFSpeaker{IPAddress: mockServer.URL[7:]})
6477

65-
// Call GetRadioMenu - this should set RadioBaseURL from redirect
66-
_, err := client.GetRadioMenu()
78+
// Call GetRadioMenu - this should follow the redirect and set RadioBaseURL
79+
resp, err := client.GetRadioMenu()
6780
if err != nil {
6881
t.Fatalf("GetRadioMenu failed: %v", err)
6982
}
7083

84+
// Validate rows were returned
85+
if len(resp.Rows) != 1 {
86+
t.Errorf("Expected 1 row, got %d", len(resp.Rows))
87+
}
88+
7189
// Validate RadioBaseURL was set
7290
if client.RadioBaseURL != "airable:https://mock.airable.io/airable/radios" {
7391
t.Errorf("Expected RadioBaseURL 'airable:https://mock.airable.io/airable/radios', got '%s'", client.RadioBaseURL)
@@ -227,27 +245,45 @@ func TestContentItem_GetThumbnail(t *testing.T) {
227245
}
228246

229247
func TestAirableClient_GetPodcastMenu(t *testing.T) {
230-
// Mock server to simulate the API
248+
// Mock server to simulate the API with redirect handling
231249
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
232250
if r.URL.Path == "/api/getRows" {
233-
response := RowsResponse{
234-
RowsCount: 0,
235-
RowsRedirect: "airable:https://mock.airable.io/airable/feeds",
251+
path := r.URL.Query().Get("path")
252+
if path == "airable:https://mock.airable.io/airable/feeds" {
253+
// Second request after redirect - return actual rows
254+
response := RowsResponse{
255+
RowsCount: 1,
256+
Rows: []ContentItem{
257+
{Title: "Popular", Type: "container", Path: "airable:https://mock.airable.io/airable/feeds/popular"},
258+
},
259+
}
260+
_ = json.NewEncoder(w).Encode(response)
261+
} else {
262+
// Initial request - return redirect
263+
response := RowsResponse{
264+
RowsCount: 0,
265+
RowsRedirect: "airable:https://mock.airable.io/airable/feeds",
266+
}
267+
_ = json.NewEncoder(w).Encode(response)
236268
}
237-
_ = json.NewEncoder(w).Encode(response)
238269
}
239270
}))
240271
defer mockServer.Close()
241272

242273
// Initialize AirableClient with the mock server
243274
client := NewAirableClient(&KEFSpeaker{IPAddress: mockServer.URL[7:]})
244275

245-
// Call GetPodcastMenu
246-
_, err := client.GetPodcastMenu()
276+
// Call GetPodcastMenu - this should follow the redirect and set PodcastBaseURL
277+
resp, err := client.GetPodcastMenu()
247278
if err != nil {
248279
t.Fatalf("GetPodcastMenu failed: %v", err)
249280
}
250281

282+
// Validate rows were returned
283+
if len(resp.Rows) != 1 {
284+
t.Errorf("Expected 1 row, got %d", len(resp.Rows))
285+
}
286+
251287
// Validate PodcastBaseURL was set
252288
if client.PodcastBaseURL != "airable:https://mock.airable.io/airable/feeds" {
253289
t.Errorf("Expected PodcastBaseURL 'airable:https://mock.airable.io/airable/feeds', got '%s'", client.PodcastBaseURL)

kefw2/browse_cache.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,7 @@ func (c *BrowseCache) ResolveDisplayPath(displayPath, service string) (apiPath s
351351
// ============================================
352352

353353
// ParseHierarchicalPath splits a path into segments, handling escaped characters.
354-
// For example: "Rock/Classic Rock" -> ["Rock", "Classic Rock"]
354+
// For example: "Rock/Classic Rock" -> ["Rock", "Classic Rock"].
355355
func ParseHierarchicalPath(path string) []string {
356356
if path == "" {
357357
return nil

0 commit comments

Comments
 (0)