diff --git a/cmd/soundtouch-cli/cmd_discover.go b/cmd/soundtouch-cli/cmd_discover.go index 7c57062..1ab71c6 100644 --- a/cmd/soundtouch-cli/cmd_discover.go +++ b/cmd/soundtouch-cli/cmd_discover.go @@ -12,29 +12,42 @@ import ( // discoverDevices handles device discovery command func discoverDevices(c *cli.Context) error { - timeout := c.Duration("timeout") + httpTimeout := c.Duration("timeout") showAll := c.Bool("all") fmt.Printf("Discovering SoundTouch devices...\n") - if showAll { - fmt.Printf("Timeout: %v\n", timeout) - fmt.Printf("Mode: Detailed information\n") - } - - fmt.Println() - // Load configuration cfg, err := config.LoadFromEnv() if err != nil { cfg = config.DefaultConfig() } - // Override discovery timeout if provided - if timeout > 0 { - cfg.DiscoveryTimeout = timeout + // Only override discovery timeout if user explicitly provided --timeout flag + // This respects DISCOVERY_TIMEOUT from .env file when no flag is provided + if c.IsSet("timeout") { + cfg.HTTPTimeout = httpTimeout + // Set discovery timeout to be 2x HTTP timeout (min 5s, max 30s) + discoveryTimeout := httpTimeout * 2 + if discoveryTimeout < 5*time.Second { + discoveryTimeout = 5 * time.Second + } + + if discoveryTimeout > 30*time.Second { + discoveryTimeout = 30 * time.Second + } + + cfg.DiscoveryTimeout = discoveryTimeout } + if showAll { + fmt.Printf("HTTP Timeout: %v\n", cfg.HTTPTimeout) + fmt.Printf("Discovery Timeout: %v\n", cfg.DiscoveryTimeout) + fmt.Printf("Mode: Detailed information\n") + } + + fmt.Println() + // Create discovery service discoveryService := discovery.NewUnifiedDiscoveryService(cfg) diff --git a/cmd/soundtouch-cli/main.go b/cmd/soundtouch-cli/main.go index 2f2b35a..9a88993 100644 --- a/cmd/soundtouch-cli/main.go +++ b/cmd/soundtouch-cli/main.go @@ -68,7 +68,6 @@ func main() { { Name: "name", Usage: "Get or set device name", - Flags: CommonFlags, Before: RequireHost, Subcommands: []*cli.Command{ { diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md index 9a5193f..d6c71e1 100644 --- a/docs/CLAUDE.md +++ b/docs/CLAUDE.md @@ -80,6 +80,14 @@ When creating test data for API endpoints, prefer real device responses over hyp - **Coverage**: Use multiple real devices to cover different response variations - **Non-responsive endpoints**: Some endpoints like `/trackInfo` may not respond or exist on all devices +### 9. File Operations Safety + +- **Never delete files** - use move/rename instead when possible +- **Ask before destructive operations** - especially for config files (.env, *.config, etc.) +- **Prefer non-destructive operations** - copy, move, rename over delete +- **Respect user data** - treat all user files as potentially containing sensitive data +- **Configuration files are sacred** - .env, config files may contain secrets and personal settings + ## Additional Notes - **Language: English** for code, commits, labels, and text in code diff --git a/pkg/discovery/mdns.go b/pkg/discovery/mdns.go index 8b2df53..3819bbc 100644 --- a/pkg/discovery/mdns.go +++ b/pkg/discovery/mdns.go @@ -47,18 +47,38 @@ func (m *MDNSDiscoveryService) DiscoverDevices(ctx context.Context) ([]*models.D log.Printf("mDNS: Starting discovery for service '%s.%s' with timeout %v", soundTouchServiceType, soundTouchDomain, m.timeout) - // Query for SoundTouch devices - // Note: hashicorp/mdns expects service and domain separately + // IPv4-only query to fix "no route to host" errors on IPv6 + // This addresses the issue where hashicorp/mdns fails with: + // "write udp6 [::]:port->[ff02::fb]:5353: sendto: no route to host" + // The trailing dot in service names is handled correctly by separating + // service and domain parameters as expected by the library. err := mdns.Query(&mdns.QueryParam{ - Service: "_soundtouch._tcp", - Domain: "local.", - Timeout: m.timeout, - Entries: entries, + Service: "_soundtouch._tcp", + Domain: "local.", + Timeout: m.timeout, + Entries: entries, + DisableIPv6: true, // Force IPv4 only to avoid routing issues + Interface: m.getIPv4Interface(), // Use specific interface if available }) if err != nil { - log.Printf("mDNS query completed with error: %v", err) + log.Printf("mDNS IPv4 query failed: %v", err) + + // Fallback to standard query (both IPv4 and IPv6) + log.Printf("mDNS: Falling back to standard query...") + + err = mdns.Query(&mdns.QueryParam{ + Service: "_soundtouch._tcp", + Domain: "local.", + Timeout: m.timeout, + Entries: entries, + }) + if err != nil { + log.Printf("mDNS query completed with error: %v", err) + } else { + log.Printf("mDNS query completed successfully") + } } else { - log.Printf("mDNS query completed successfully") + log.Printf("mDNS IPv4 query completed successfully") } }() @@ -78,6 +98,12 @@ func (m *MDNSDiscoveryService) DiscoverDevices(ctx context.Context) ([]*models.D log.Printf("mDNS: Received service entry: Name='%s', Host='%s', Port=%d, AddrV4=%v, AddrV6=%v", entry.Name, entry.Host, entry.Port, entry.AddrV4, entry.AddrV6) + // Only process SoundTouch devices + if !strings.Contains(entry.Name, "_soundtouch._tcp") { + log.Printf("mDNS: Skipping non-SoundTouch service: %s", entry.Name) + continue + } + device := m.serviceEntryToDevice(entry) if device != nil { log.Printf("mDNS: Successfully converted to device: %s at %s:%d", device.Name, device.Host, device.Port) @@ -170,6 +196,11 @@ func (m *MDNSDiscoveryService) serviceEntryToDevice(entry *mdns.ServiceEntry) *m name = strings.TrimSuffix(name, "."+soundTouchServiceType+"."+soundTouchDomain) } + // Unescape any escaped characters in the name (common in mDNS) + name = strings.ReplaceAll(name, `\ `, " ") + name = strings.ReplaceAll(name, `\.`, ".") + name = strings.ReplaceAll(name, `\\`, `\`) + device := &models.DiscoveredDevice{ Host: host, Port: port, @@ -182,3 +213,47 @@ func (m *MDNSDiscoveryService) serviceEntryToDevice(entry *mdns.ServiceEntry) *m return device } + +// getIPv4Interface returns the first suitable IPv4 network interface +func (m *MDNSDiscoveryService) getIPv4Interface() *net.Interface { + interfaces, err := net.Interfaces() + if err != nil { + log.Printf("mDNS: Failed to get network interfaces: %v", err) + return nil + } + + for _, iface := range interfaces { + // Skip loopback, down interfaces, and point-to-point interfaces + if iface.Flags&net.FlagLoopback != 0 || + iface.Flags&net.FlagUp == 0 || + iface.Flags&net.FlagPointToPoint != 0 { + continue + } + + // Check if this interface has IPv4 addresses + addrs, err := iface.Addrs() + if err != nil { + continue + } + + hasIPv4 := false + + for _, addr := range addrs { + if ipNet, ok := addr.(*net.IPNet); ok { + if ipNet.IP.To4() != nil && !ipNet.IP.IsLoopback() { + hasIPv4 = true + break + } + } + } + + if hasIPv4 { + log.Printf("mDNS: Using IPv4 interface: %s", iface.Name) + return &iface + } + } + + log.Printf("mDNS: No suitable IPv4 interface found") + + return nil +}