Skip to content

Commit 194e712

Browse files
authored
Add UPnP library search with local indexing (#22)
* Add UPnP library search with local indexing - Add GetAllServerTracks() for recursive UPnP server scanning - Add track index system with JSON cache persistence - Add fuzzy search on title/artist/album fields - Add 'upnp search <query>' command with interactive picker - Add 'upnp index' command to view/rebuild index - Add 'config upnp index container' to set default container path - Add container path navigation and tab completion - Add HTTP timeout option for large library scanning - Fix cache status to show correct entries and size - Add 24-hour auto-refresh for stale indexes * Add `upnp search` and `upnp index` to make search go fast * Add v0.2.2 changelog for UPnP search feature
1 parent 5274aed commit 194e712

9 files changed

Lines changed: 1135 additions & 70 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.2] - 2025-02-03
11+
12+
### Added
13+
14+
- **UPnP Library Search**: New local search index for instant track searching
15+
- `upnp search [query]` - Search by title, artist, or album
16+
- `upnp search` (no query) - Browse full library with interactive filter
17+
- `upnp index` - View index status
18+
- `upnp index --rebuild` - Rebuild the search index
19+
- `upnp index --container "path"` - Index from specific folder
20+
- `config upnp index container` - Set default container for indexing
21+
- Ranked search results with exact matches first
22+
- Multi-word queries work across fields (e.g., `public enemy uzi`)
23+
24+
- **Content Picker Improvements**
25+
- Page Up/Page Down navigation for faster scrolling
26+
- Filter now matches on artist/album in addition to title
27+
28+
### Changed
29+
30+
- `upnp play` now recursively scans sub-containers (plays all albums under an artist)
31+
- `upnp search` accepts multiple arguments without quotes
32+
33+
### Fixed
34+
35+
- Fixed `cache status` to show correct cache file and entry count
36+
1037
## [0.2.1] - 2026-02-02
1138

1239
### Added
@@ -331,7 +358,8 @@ Implemented by: `Source`, `SpeakerStatus`, `CableMode`
331358

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

334-
[Unreleased]: https://github.com/hilli/go-kef-w2/compare/v0.2.1...HEAD
361+
[Unreleased]: https://github.com/hilli/go-kef-w2/compare/v0.2.2...HEAD
362+
[0.2.2]: https://github.com/hilli/go-kef-w2/compare/v0.2.1...v0.2.2
335363
[0.2.1]: https://github.com/hilli/go-kef-w2/compare/v0.2.0...v0.2.1
336364
[0.2.0]: https://github.com/hilli/go-kef-w2/compare/v0.1.0...v0.2.0
337365
[0.1.0]: https://github.com/hilli/go-kef-w2/releases/tag/v0.1.0

README.md

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -190,16 +190,60 @@ kefw2 podcast browse
190190
Play music from local network media servers:
191191

192192
```shell
193-
# Browse a server (with tab completion)
194-
kefw2 upnp browse "My NAS/Music/Albums"
193+
# Configure default UPnP server
194+
kefw2 config upnp server default "Plex Media Server: homesrv"
195+
196+
# Browse a server interactively
197+
kefw2 upnp browse
198+
199+
# Browse a specific path (with tab completion)
200+
kefw2 upnp browse "Music/Albums"
195201

196202
# Play media from server
197-
kefw2 upnp play "My NAS/Music/Jazz/Album/Track.flac"
203+
kefw2 upnp play "Music/Jazz/Album/Track.flac"
204+
```
198205

199-
# Configure default UPnP server
200-
kefw2 config upnp server "My NAS"
206+
#### UPnP Search
207+
208+
Search your entire music library instantly with a local index:
209+
210+
```shell
211+
# Search for tracks by title, artist, or album
212+
kefw2 upnp search "beatles"
213+
kefw2 upnp search "abbey road"
214+
kefw2 upnp search "come together beatles"
215+
216+
# Add search result to queue instead of playing
217+
kefw2 upnp search -q "bohemian"
218+
```
219+
220+
The search feature builds a local index of your UPnP music library for instant results.
221+
The index is cached and automatically refreshes after 24 hours.
222+
223+
#### Index Configuration
224+
225+
For large libraries (like Plex), you can configure which folder to index to avoid
226+
scanning duplicate views (By Genre, By Album, etc. contain the same tracks):
227+
228+
```shell
229+
# Set the container path to index (with tab completion)
230+
kefw2 config upnp index container "Music/Hilli's Music/By Folder"
231+
232+
# Show current index status
233+
kefw2 upnp index
234+
235+
# Rebuild the index (uses configured container automatically)
236+
kefw2 upnp index --rebuild
237+
238+
# Override container for a one-time rebuild
239+
kefw2 upnp index --rebuild --container "Music/My Library/By Album"
240+
241+
# Clear the container setting (index entire server)
242+
kefw2 config upnp index container ""
201243
```
202244

245+
The container path uses `/` as separator and supports tab completion at each level.
246+
203247
### Queue Management
204248

205249
Manage the playback queue:
@@ -296,6 +340,7 @@ kefw2 events --json
296340
- [x] Play Podcasts
297341
- [x] Play from UPnP/DLNA media servers
298342
- [x] Queue management
343+
- [x] UPnP library search with local indexing
299344
- [ ] Restore speaker settings/eq profiles from file
300345
- [ ] Play titles from built-in music streaming services (Amazon Music, Deezer, Qobuz, Spotify, Tidal)
301346

cmd/kefw2/cmd/cache.go

Lines changed: 79 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -350,28 +350,92 @@ var cacheStatusCmd = &cobra.Command{
350350
Use: "status",
351351
Short: "Show cache statistics",
352352
Run: func(cmd *cobra.Command, args []string) {
353-
if browseCache == nil {
354-
errorPrinter.Println("Cache not initialized")
355-
return
353+
// Get cache directory
354+
cacheDir, err := os.UserCacheDir()
355+
if err != nil {
356+
cacheDir = os.TempDir()
357+
}
358+
cacheDir = filepath.Join(cacheDir, "kefw2")
359+
360+
headerPrinter.Println("API Response Cache:")
361+
rowsCachePath := filepath.Join(cacheDir, "rows_cache.json")
362+
if info, err := os.Stat(rowsCachePath); err == nil {
363+
// Read and parse the cache file to count entries
364+
data, readErr := os.ReadFile(rowsCachePath)
365+
var entryCount int
366+
var oldestAge time.Duration
367+
if readErr == nil {
368+
var entries map[string]json.RawMessage
369+
if json.Unmarshal(data, &entries) == nil {
370+
entryCount = len(entries)
371+
// Try to find oldest entry
372+
type entryWithTime struct {
373+
FetchedAt time.Time `json:"fetched_at"`
374+
}
375+
var oldest time.Time
376+
for _, raw := range entries {
377+
var e entryWithTime
378+
if json.Unmarshal(raw, &e) == nil && !e.FetchedAt.IsZero() {
379+
if oldest.IsZero() || e.FetchedAt.Before(oldest) {
380+
oldest = e.FetchedAt
381+
}
382+
}
383+
}
384+
if !oldest.IsZero() {
385+
oldestAge = time.Since(oldest)
386+
}
387+
}
388+
}
389+
contentPrinter.Printf(" Location: %s\n", rowsCachePath)
390+
contentPrinter.Printf(" Entries: %d\n", entryCount)
391+
contentPrinter.Printf(" Size: %s\n", formatBytes(info.Size()))
392+
if oldestAge > 0 {
393+
contentPrinter.Printf(" Oldest: %v ago\n", oldestAge.Round(time.Second))
394+
}
395+
} else {
396+
contentPrinter.Println(" No cache file found")
356397
}
357398

358-
entries, size, oldest := browseCache.Status()
399+
// Show TTL settings from config
400+
if browseCache != nil {
401+
contentPrinter.Println("\n TTL Settings:")
402+
contentPrinter.Printf(" Radio: %v\n", browseCache.GetTTL("radio"))
403+
contentPrinter.Printf(" Podcast: %v\n", browseCache.GetTTL("podcast"))
404+
contentPrinter.Printf(" UPnP: %v\n", browseCache.GetTTL("upnp"))
405+
}
359406

360-
headerPrinter.Println("Cache Status:")
361-
contentPrinter.Printf(" Enabled: %v\n", browseCache.IsEnabled())
362-
contentPrinter.Printf(" Location: %s\n", browseCache.CacheDir())
363-
contentPrinter.Printf(" Entries: %d\n", entries)
364-
contentPrinter.Printf(" Size: %d bytes\n", size)
365-
if entries > 0 {
366-
contentPrinter.Printf(" Oldest: %v ago\n", oldest.Round(time.Second))
407+
// Show UPnP track index status
408+
headerPrinter.Println("\nUPnP Track Index:")
409+
index, err := LoadTrackIndex()
410+
if err != nil || index == nil {
411+
contentPrinter.Println(" No index found")
412+
contentPrinter.Println(" Run 'kefw2 upnp index --rebuild' to create one")
413+
} else {
414+
contentPrinter.Printf(" Server: %s\n", index.ServerName)
415+
if index.ContainerName != "" {
416+
contentPrinter.Printf(" Container: %s\n", index.ContainerName)
417+
}
418+
contentPrinter.Printf(" Tracks: %d\n", index.TrackCount)
419+
contentPrinter.Printf(" Age: %v\n", time.Since(index.IndexedAt).Round(time.Second))
420+
contentPrinter.Printf(" Location: %s\n", getTrackIndexPath())
367421
}
368-
contentPrinter.Println("\nTTL Settings:")
369-
contentPrinter.Printf(" Radio: %v\n", browseCache.GetTTL("radio"))
370-
contentPrinter.Printf(" Podcast: %v\n", browseCache.GetTTL("podcast"))
371-
contentPrinter.Printf(" UPnP: %v\n", browseCache.GetTTL("upnp"))
372422
},
373423
}
374424

425+
// formatBytes formats bytes as human-readable string
426+
func formatBytes(bytes int64) string {
427+
const unit = 1024
428+
if bytes < unit {
429+
return fmt.Sprintf("%d bytes", bytes)
430+
}
431+
div, exp := int64(unit), 0
432+
for n := bytes / unit; n >= unit; n /= unit {
433+
div *= unit
434+
exp++
435+
}
436+
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
437+
}
438+
375439
func init() {
376440
rootCmd.AddCommand(cacheCmd)
377441
cacheCmd.AddCommand(cacheClearCmd)

cmd/kefw2/cmd/config_upnp.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,9 +152,139 @@ func UPnPServerCompletion(cmd *cobra.Command, args []string, toComplete string)
152152
return completions, cobra.ShellCompDirectiveNoFileComp
153153
}
154154

155+
// upnpIndexConfigCmd is the parent for index config subcommands
156+
var upnpIndexConfigCmd = &cobra.Command{
157+
Use: "index",
158+
Short: "Configure UPnP search index settings",
159+
Long: `Configure settings for the UPnP search index, including the container path to index.`,
160+
Run: func(cmd *cobra.Command, args []string) {
161+
_ = cmd.Help()
162+
},
163+
}
164+
165+
// upnpIndexContainerCmd shows or sets the index container path
166+
var upnpIndexContainerCmd = &cobra.Command{
167+
Use: "container [path]",
168+
Short: "Show or set the container path for indexing",
169+
Long: `Show the current container path for indexing, or set a new one.
170+
171+
The container path determines which folder to start indexing from.
172+
Use "/" as separator for nested paths.
173+
174+
Without arguments, displays the current setting.
175+
With a path, sets that as the default container to index.
176+
177+
Examples:
178+
kefw2 config upnp index container # Show current
179+
kefw2 config upnp index container "Music" # Index Music folder
180+
kefw2 config upnp index container "Music/Hilli's Music/By Folder" # Index specific folder
181+
kefw2 config upnp index container "" # Clear (index entire server)`,
182+
Run: func(cmd *cobra.Command, args []string) {
183+
if len(args) == 0 {
184+
// Show current setting
185+
containerPath := viper.GetString("upnp.index_container")
186+
if containerPath == "" {
187+
contentPrinter.Println("No index container configured (will index entire server).")
188+
contentPrinter.Println("Use 'kefw2 config upnp index container <path>' to set one.")
189+
return
190+
}
191+
headerPrinter.Print("Index container: ")
192+
contentPrinter.Println(containerPath)
193+
return
194+
}
195+
196+
// Set new container path
197+
containerPath := args[0]
198+
199+
// If a path is provided, validate it exists
200+
if containerPath != "" {
201+
serverPath := viper.GetString("upnp.default_server_path")
202+
if serverPath == "" {
203+
exitWithError("No default UPnP server configured. Set one first with: kefw2 config upnp server default <name>")
204+
}
205+
206+
client := kefw2.NewAirableClient(currentSpeaker)
207+
_, resolvedPath, err := findContainerByPath(client, serverPath, containerPath)
208+
if err != nil {
209+
exitWithError("Invalid container path: %v", err)
210+
}
211+
// Use the resolved path (with proper casing)
212+
containerPath = resolvedPath
213+
}
214+
215+
// Save to config
216+
viper.Set("upnp.index_container", containerPath)
217+
err := viper.WriteConfig()
218+
exitOnError(err, "Error saving config")
219+
220+
if containerPath == "" {
221+
taskConpletedPrinter.Println("Index container cleared (will index entire server)")
222+
} else {
223+
taskConpletedPrinter.Print("Index container set: ")
224+
contentPrinter.Println(containerPath)
225+
}
226+
},
227+
ValidArgsFunction: UPnPContainerCompletion,
228+
}
229+
230+
// UPnPContainerCompletion provides tab completion for container paths
231+
func UPnPContainerCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
232+
if len(args) > 0 {
233+
return nil, cobra.ShellCompDirectiveNoFileComp
234+
}
235+
236+
if currentSpeaker == nil || currentSpeaker.IPAddress == "" {
237+
return nil, cobra.ShellCompDirectiveNoFileComp
238+
}
239+
240+
serverPath := viper.GetString("upnp.default_server_path")
241+
if serverPath == "" {
242+
return nil, cobra.ShellCompDirectiveNoFileComp
243+
}
244+
245+
client := kefw2.NewAirableClient(currentSpeaker)
246+
247+
// Parse the path to complete
248+
// e.g., "Music/Hilli" -> parentPath="Music", prefix="Hilli"
249+
var parentPath, prefix string
250+
if idx := strings.LastIndex(toComplete, "/"); idx >= 0 {
251+
parentPath = toComplete[:idx]
252+
prefix = toComplete[idx+1:]
253+
} else {
254+
parentPath = ""
255+
prefix = toComplete
256+
}
257+
258+
// Get containers at the parent path
259+
containers, err := listContainersAtPath(client, serverPath, parentPath)
260+
if err != nil {
261+
return nil, cobra.ShellCompDirectiveNoFileComp
262+
}
263+
264+
var completions []string
265+
prefixLower := strings.ToLower(prefix)
266+
for _, name := range containers {
267+
if strings.HasPrefix(strings.ToLower(name), prefixLower) {
268+
// Build the full path for completion
269+
var fullPath string
270+
if parentPath != "" {
271+
fullPath = parentPath + "/" + name
272+
} else {
273+
fullPath = name
274+
}
275+
completions = append(completions, fullPath)
276+
}
277+
}
278+
279+
// Don't add space after completion (user might want to continue the path)
280+
return completions, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp
281+
}
282+
155283
func init() {
156284
ConfigCmd.AddCommand(upnpConfigCmd)
157285
upnpConfigCmd.AddCommand(upnpServerConfigCmd)
158286
upnpServerConfigCmd.AddCommand(upnpServerDefaultCmd)
159287
upnpServerConfigCmd.AddCommand(upnpServerListCmd)
288+
upnpConfigCmd.AddCommand(upnpIndexConfigCmd)
289+
upnpIndexConfigCmd.AddCommand(upnpIndexContainerCmd)
160290
}

0 commit comments

Comments
 (0)