Skip to content

Commit 5274aed

Browse files
authored
Fixes (#21)
* fixes the podcast play/search handling * Remove queue add, fix queue picker's delete and clear commands * Add full UPnP pagination and recursive container queue support - Use BrowseContainerAll() for interactive browsing to fetch all items - Add AddContainerToQueue callback to recursively add all tracks from containers - Fix UPnP IsPlayable to treat all containers as navigable (not playable) - Add GetContainerTracksRecursive() for finding all tracks in nested folders - Add BrowseUPnPByDisplayPathAll() for full pagination on display paths * changes added
1 parent 21e60a3 commit 5274aed

6 files changed

Lines changed: 259 additions & 124 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [0.2.1] - 2026-02-02
11+
12+
### Added
13+
14+
- Full UPnP pagination support with `BrowseContainerAll()` for fetching all items in large directories
15+
- `AddContainerToQueue` callback for recursively adding all tracks from containers
16+
- `GetContainerTracksRecursive()` for finding all tracks in nested folders
17+
- `BrowseUPnPByDisplayPathAll()` for full pagination on display paths
18+
- Enhanced keyboard shortcuts for queue management
19+
20+
### Changed
21+
22+
- Interactive content picker now uses `BrowseContainerAll()` to fetch all items
23+
- Improved recursive track addition from containers
24+
- More accurate item filtering and selection in content picker
25+
- Consistent use of full container browsing across UPnP and podcast features
26+
27+
### Fixed
28+
29+
- Fixed podcast play/search handling
30+
- Fixed queue picker's delete and clear commands
31+
- Fixed UPnP `IsPlayable` to treat all containers as navigable (not playable)
32+
33+
### Removed
34+
35+
- Removed `queue add` command (use interactive content picker instead)
36+
1037
## [0.2.0] - 2026-02-01
1138

1239
### Added
@@ -304,6 +331,7 @@ Implemented by: `Source`, `SpeakerStatus`, `CableMode`
304331

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

307-
[Unreleased]: https://github.com/hilli/go-kef-w2/compare/v0.2.0...HEAD
334+
[Unreleased]: https://github.com/hilli/go-kef-w2/compare/v0.2.1...HEAD
335+
[0.2.1]: https://github.com/hilli/go-kef-w2/compare/v0.2.0...v0.2.1
308336
[0.2.0]: https://github.com/hilli/go-kef-w2/compare/v0.1.0...v0.2.0
309337
[0.1.0]: https://github.com/hilli/go-kef-w2/releases/tag/v0.1.0

cmd/kefw2/cmd/content_picker.go

Lines changed: 123 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,17 @@ type ContentPickerCallbacks struct {
5959
// AddToQueue is called when a playable item is selected with ActionAddToQueue.
6060
AddToQueue func(item *kefw2.ContentItem) error
6161

62+
// AddContainerToQueue is called when Ctrl+a is pressed on a container.
63+
// Should recursively find all tracks and add them to the queue.
64+
// Returns the count of tracks added.
65+
AddContainerToQueue func(item *kefw2.ContentItem) (int, error)
66+
67+
// DeleteFromQueue is called when Ctrl+d is pressed to delete an item from the queue.
68+
DeleteFromQueue func(item *kefw2.ContentItem) error
69+
70+
// ClearQueue is called when Ctrl+x is pressed to clear the entire queue.
71+
ClearQueue func() error
72+
6273
// IsPlayable determines if an item is playable (not a container to navigate into).
6374
IsPlayable func(item *kefw2.ContentItem) bool
6475
}
@@ -112,6 +123,7 @@ type ContentPickerConfig struct {
112123
CurrentPath string
113124
Action ContentAction
114125
Callbacks ContentPickerCallbacks
126+
SearchQuery string // Optional: if set, auto-focus on matching item
115127
}
116128

117129
// NewContentPickerModel creates a new content picker model.
@@ -122,7 +134,7 @@ func NewContentPickerModel(cfg ContentPickerConfig) ContentPickerModel {
122134
ti.CharLimit = 100
123135
ti.Width = 40
124136

125-
return ContentPickerModel{
137+
model := ContentPickerModel{
126138
serviceType: cfg.ServiceType,
127139
callbacks: cfg.Callbacks,
128140
action: cfg.Action,
@@ -133,6 +145,20 @@ func NewContentPickerModel(cfg ContentPickerConfig) ContentPickerModel {
133145
title: cfg.Title,
134146
currentPath: cfg.CurrentPath,
135147
}
148+
149+
// Auto-focus on matching item if search query provided
150+
if cfg.SearchQuery != "" {
151+
query := strings.TrimSuffix(cfg.SearchQuery, "/")
152+
for i, item := range cfg.Items {
153+
itemTitle := strings.TrimSuffix(item.Title, "/")
154+
if strings.EqualFold(itemTitle, query) {
155+
model.cursor = i
156+
break
157+
}
158+
}
159+
}
160+
161+
return model
136162
}
137163

138164
// Init implements tea.Model.
@@ -266,21 +292,70 @@ func (m ContentPickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
266292
return m, tea.Quit
267293
}
268294

269-
case "a":
295+
case "ctrl+a":
270296
// Add selected item to queue without exiting
271297
if len(m.filtered) > 0 && m.cursor < len(m.filtered) {
272298
selected := &m.filtered[m.cursor]
273-
if m.isItemPlayable(selected) && m.callbacks.AddToQueue != nil {
274-
err := m.callbacks.AddToQueue(selected)
299+
if m.isItemPlayable(selected) {
300+
// Single track
301+
if m.callbacks.AddToQueue != nil {
302+
err := m.callbacks.AddToQueue(selected)
303+
if err != nil {
304+
m.statusMsg = fmt.Sprintf("Error: %v", err)
305+
} else {
306+
m.statusMsg = fmt.Sprintf("Added to queue: %s", selected.Title)
307+
}
308+
}
309+
} else {
310+
// Container - add all tracks recursively
311+
if m.callbacks.AddContainerToQueue != nil {
312+
count, err := m.callbacks.AddContainerToQueue(selected)
313+
if err != nil {
314+
m.statusMsg = fmt.Sprintf("Error: %v", err)
315+
} else {
316+
m.statusMsg = fmt.Sprintf("Added %d tracks to queue from: %s", count, selected.Title)
317+
}
318+
}
319+
}
320+
}
321+
return m, nil
322+
323+
case "ctrl+d":
324+
// Delete selected item from queue
325+
if len(m.filtered) > 0 && m.cursor < len(m.filtered) {
326+
selected := &m.filtered[m.cursor]
327+
if m.callbacks.DeleteFromQueue != nil {
328+
err := m.callbacks.DeleteFromQueue(selected)
275329
if err != nil {
276330
m.statusMsg = fmt.Sprintf("Error: %v", err)
277331
} else {
278-
m.statusMsg = fmt.Sprintf("Added to queue: %s", selected.Title)
332+
m.statusMsg = fmt.Sprintf("Deleted: %s", selected.Title)
333+
// Remove from allItems and filtered
334+
m.allItems = removeItem(m.allItems, selected)
335+
m.filtered = removeItem(m.filtered, selected)
336+
if m.cursor >= len(m.filtered) && m.cursor > 0 {
337+
m.cursor--
338+
}
279339
}
280340
}
281341
}
282342
return m, nil
283343

344+
case "ctrl+x":
345+
// Clear entire queue
346+
if m.callbacks.ClearQueue != nil {
347+
err := m.callbacks.ClearQueue()
348+
if err != nil {
349+
m.statusMsg = fmt.Sprintf("Error: %v", err)
350+
} else {
351+
m.statusMsg = "Queue cleared"
352+
m.allItems = nil
353+
m.filtered = nil
354+
m.cursor = 0
355+
}
356+
}
357+
return m, nil
358+
284359
default:
285360
// Handle text input for filtering
286361
var cmd tea.Cmd
@@ -296,6 +371,17 @@ func (m ContentPickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
296371
return m, cmd
297372
}
298373

374+
// removeItem removes an item from a slice by matching Path.
375+
func removeItem(items []kefw2.ContentItem, target *kefw2.ContentItem) []kefw2.ContentItem {
376+
result := make([]kefw2.ContentItem, 0, len(items)-1)
377+
for _, item := range items {
378+
if item.Path != target.Path {
379+
result = append(result, item)
380+
}
381+
}
382+
return result
383+
}
384+
299385
// applyFilter filters the items based on the current filter input.
300386
func (m *ContentPickerModel) applyFilter() {
301387
query := m.filterInput.Value()
@@ -417,11 +503,17 @@ func (m ContentPickerModel) View() string {
417503
case ActionAddToQueue:
418504
actionHint = "add to queue"
419505
}
420-
queueHint := ""
506+
extraHints := ""
421507
if m.callbacks.AddToQueue != nil {
422-
queueHint = " | a: add to queue"
508+
extraHints += " | Ctrl+a: add to queue"
423509
}
424-
statusText := fmt.Sprintf("↑/↓: navigate | Type to filter | Enter: %s%s | Esc: quit", actionHint, queueHint)
510+
if m.callbacks.DeleteFromQueue != nil {
511+
extraHints += " | Ctrl+d: delete"
512+
}
513+
if m.callbacks.ClearQueue != nil {
514+
extraHints += " | Ctrl+x: clear"
515+
}
516+
statusText := fmt.Sprintf("↑/↓: navigate | Type to filter | Enter: %s%s | Esc: quit", actionHint, extraHints)
425517
b.WriteString(m.styles.Status.Render(statusText))
426518

427519
return b.String()
@@ -498,8 +590,8 @@ func DefaultPodcastCallbacks(client *kefw2.AirableClient) ContentPickerCallbacks
498590
return client.AddToQueue([]kefw2.ContentItem{*item}, false)
499591
},
500592
IsPlayable: func(item *kefw2.ContentItem) bool {
501-
// Podcasts (containers with episodes) are navigable, episodes are playable
502-
return item.Type != "container" || item.ContainerPlayable
593+
// Only actual audio episodes are playable; all containers should be navigable
594+
return item.Type == "audio"
503595
},
504596
}
505597
}
@@ -509,7 +601,8 @@ func DefaultUPnPCallbacks(client *kefw2.AirableClient) ContentPickerCallbacks {
509601
return ContentPickerCallbacks{
510602
Navigate: func(item *kefw2.ContentItem, currentPath string) ([]kefw2.ContentItem, string, error) {
511603
// For UPnP, navigate using the item's Path directly
512-
resp, err := client.BrowseContainer(item.Path)
604+
// Use BrowseContainerAll to fetch all items (not just first 100)
605+
resp, err := client.BrowseContainerAll(item.Path)
513606
if err != nil {
514607
return nil, "", err
515608
}
@@ -526,8 +619,26 @@ func DefaultUPnPCallbacks(client *kefw2.AirableClient) ContentPickerCallbacks {
526619
// UPnP supports adding to queue
527620
return client.AddToQueue([]kefw2.ContentItem{*item}, false)
528621
},
622+
AddContainerToQueue: func(item *kefw2.ContentItem) (int, error) {
623+
// Recursively get all tracks from the container
624+
tracks, err := client.GetContainerTracksRecursive(item.Path)
625+
if err != nil {
626+
return 0, err
627+
}
628+
if len(tracks) == 0 {
629+
return 0, fmt.Errorf("no audio tracks found in container")
630+
}
631+
err = client.AddToQueue(tracks, false)
632+
if err != nil {
633+
return 0, err
634+
}
635+
return len(tracks), nil
636+
},
529637
IsPlayable: func(item *kefw2.ContentItem) bool {
530-
return item.Type != "container" || item.ContainerPlayable
638+
// For UPnP, only audio tracks are directly playable.
639+
// Containers (artists, albums, folders) should always be navigable,
640+
// even if ContainerPlayable is true.
641+
return item.Type == "audio"
531642
},
532643
}
533644
}

cmd/kefw2/cmd/podcast.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -665,13 +665,15 @@ Examples:
665665
if addToQueue {
666666
action = ActionAddToQueue
667667
}
668+
title := fmt.Sprintf("Search results for '%s'", query)
668669
result, err := RunContentPicker(ContentPickerConfig{
669670
ServiceType: ServicePodcast,
670671
Items: podcasts,
671-
Title: fmt.Sprintf("Multiple matches for '%s' - select one", query),
672+
Title: title,
672673
CurrentPath: "",
673674
Action: action,
674675
Callbacks: DefaultPodcastCallbacks(client),
676+
SearchQuery: query,
675677
})
676678
exitOnError(err, "Error")
677679

0 commit comments

Comments
 (0)