Skip to content

Commit 5ad1add

Browse files
committed
Merge branch 'main' of ssh://github.com/hilli/go-kef-w2
2 parents 62e54ea + 9c9d02b commit 5ad1add

12 files changed

Lines changed: 348 additions & 62 deletions

File tree

.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

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

kefw2/player_data.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ type PlayerResource struct {
3131

3232
// PlayerTrackRoles contains track metadata including title, icon, and media data.
3333
type PlayerTrackRoles struct {
34-
Path string `json:"path"` // "playlists:item/{index}" when playing from queue (1-based)
35-
ID string `json:"id"` // Queue index as string (1-based) when playing from queue
34+
Path string `json:"path"` // "playlists:item/{id}" when playing from queue (internal item ID, not display index)
35+
ID string `json:"id"` // Internal item ID as string when playing from queue (not the display index)
3636
Icon string `json:"icon"` // URL to album art or track icon
3737
MediaData PlayerMediaData `json:"mediaData"` // Detailed media information
3838
Title string `json:"title"` // Track title

0 commit comments

Comments
 (0)