Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -344,3 +344,39 @@ $ go-chromecast tts '<speak>Hello<break time="500ms"/>world.</speak>' \
--google-service-account=/path/to/service/account.json \
--ssml
```

### Enhanced Device Discovery (Experimental)

**Broad Search** - For complex network topologies with multiple subnets or VLANs, the `--broad-search` flag enables comprehensive device discovery that combines mDNS discovery with targeted port scanning across detected network interfaces.

```bash
# Use broad search to find devices across multiple subnets
$ go-chromecast ls --broad-search

# Works with all commands that connect to devices
$ go-chromecast status --broad-search
$ go-chromecast load media.mp4 --broad-search
$ go-chromecast ui --broad-search
```

**Multi-Subnet Scanning** - The `scan` command supports scanning specific subnets or auto-detecting all available subnets:

```bash
# Scan specific subnets
$ go-chromecast scan --subnets 192.168.4.0/24,192.168.3.0/24

# Scan all detected subnets
$ go-chromecast scan --subnets *

# Traditional single subnet scan (default behavior)
$ go-chromecast scan --cidr 192.168.1.0/24
```

**When to use:**
- Multiple VLANs or subnets in your network
- Chromecast devices not appearing in device list
- Network setups with WiFi isolation or complex routing
- Corporate networks with segmented subnets
- Chromecast Audio groups on different ports

**Note:** Broad search is slower than standard discovery as it performs more comprehensive network scanning. The enhanced discovery automatically detects your network topology and finds devices that standard mDNS discovery might miss, including Chromecast groups on non-standard ports.
5 changes: 5 additions & 0 deletions application/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ type App interface {
AddMessageFunc(f CastMessageFunc)
PlayedItems() map[string]PlayedItem
PlayableMediaType(filename string) bool
GetLocalIP() (string, error)
}

type Application struct {
Expand Down Expand Up @@ -241,6 +242,10 @@ func (a *Application) App() *cast.Application { return a.application }
func (a *Application) Media() *cast.Media { return a.media }
func (a *Application) Volume() *cast.Volume { return a.volumeReceiver }

func (a *Application) GetLocalIP() (string, error) {
return a.getLocalIP()
}

func (a *Application) AddMessageFunc(f CastMessageFunc) {
a.messageMu.Lock()
defer a.messageMu.Unlock()
Expand Down
16 changes: 8 additions & 8 deletions cmd/load-app.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,14 @@ var loadAppCmd = &cobra.Command{
the chromecast receiver app to be specified. An older list can be found
here https://gist.github.com/jloutsenhizer/8855258.
`,
Run: func(cmd *cobra.Command, args []string) {
if len(args) != 2 {
exit("requires exactly two arguments")
}
app, err := castApplication(cmd, args)
if err != nil {
exit("unable to get cast application: %v", err)
}
Run: func(cmd *cobra.Command, args []string) {
if len(args) != 2 {
exit("requires exactly two arguments")
}
app, err := castApplication(cmd, args)
if err != nil {
exit("unable to get cast application: %v", err)
}

// Optionally run a UI when playing this media:
runWithUI, _ := cmd.Flags().GetBool("with-ui")
Expand Down
81 changes: 81 additions & 0 deletions cmd/localip.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package cmd

import (
"fmt"
"net"

"github.com/spf13/cobra"
)

var localIPCmd = &cobra.Command{
Use: "localip",
Short: "Print the local IP address used by go-chromecast",
Run: func(cmd *cobra.Command, args []string) {
ifaceName, _ := cmd.Flags().GetString("iface")
ip, err := detectLocalIP(ifaceName)
if err != nil {
exit("unable to determine local IP: %v", err)
}
fmt.Println(ip)
},
}

// detectLocalIP attempts to detect the local IP address based on the network interface
func detectLocalIP(ifaceName string) (string, error) {
var iface *net.Interface
var err error

if ifaceName != "" {
iface, err = net.InterfaceByName(ifaceName)
if err != nil {
return "", err
}
}

if iface != nil {
// Use the specified interface
addrs, err := iface.Addrs()
if err != nil {
return "", err
}
for _, addr := range addrs {
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
if ipnet.IP.To4() != nil {
return ipnet.IP.String(), nil
}
}
}
} else {
// Try to find the default route interface
interfaces, err := net.Interfaces()
if err != nil {
return "", err
}

for _, iface := range interfaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}

addrs, err := iface.Addrs()
if err != nil {
continue
}

for _, addr := range addrs {
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
if ipnet.IP.To4() != nil {
return ipnet.IP.String(), nil
}
}
}
}
}

return "", fmt.Errorf("could not detect local IP address")
}

func init() {
localIPCmd.Flags().String("iface", "", "network interface to use for detecting local IP (optional)")
rootCmd.AddCommand(localIPCmd)
}
175 changes: 164 additions & 11 deletions cmd/ls.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,15 @@ package cmd

import (
"context"
"fmt"
"net"
"sort"
"sync"
"time"

"github.com/seancfoley/ipaddress-go/ipaddr"
"github.com/spf13/cobra"
"github.com/vishen/go-chromecast/application"
castdns "github.com/vishen/go-chromecast/dns"
)

Expand All @@ -30,28 +35,176 @@ var lsCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) {
ifaceName, _ := cmd.Flags().GetString("iface")
dnsTimeoutSeconds, _ := cmd.Flags().GetInt("dns-timeout")
broadSearch, _ := cmd.Flags().GetBool("broad-search")

var iface *net.Interface
var err error
if ifaceName != "" {
if iface, err = net.InterfaceByName(ifaceName); err != nil {
exit("unable to find interface %q: %v", ifaceName, err)
}
} else {
// If no interface was specified, try to auto-detect the best interface
if iface, err = detectBestInterface(); err != nil {
// If auto-detection fails, continue without interface (original behavior)
iface = nil
}
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second*time.Duration(dnsTimeoutSeconds))
defer cancel()
castEntryChan, err := castdns.DiscoverCastDNSEntries(ctx, iface)
if err != nil {
exit("unable to discover chromecast devices: %v", err)

if broadSearch {
// Use hybrid approach: mDNS + port scanning
foundDevices := performBroadSearch(iface, dnsTimeoutSeconds)
if len(foundDevices) == 0 {
outputError("no cast devices found on network")
} else {
for i, device := range foundDevices {
outputInfo("%d) device=%q device_name=%q address=\"%s:%d\" uuid=%q",
i+1, device.Device, device.DeviceName, device.AddrV4, device.Port, device.UUID)
}
}
} else {
// Use original mDNS-only approach
ctx, cancel := context.WithTimeout(context.Background(), time.Second*time.Duration(dnsTimeoutSeconds))
defer cancel()
castEntryChan, err := castdns.DiscoverCastDNSEntries(ctx, iface)
if err != nil {
exit("unable to discover chromecast devices: %v", err)
}
i := 1
for d := range castEntryChan {
outputInfo("%d) device=%q device_name=%q address=\"%s:%d\" uuid=%q", i, d.Device, d.DeviceName, d.AddrV4, d.Port, d.UUID)
i++
}
if i == 1 {
outputError("no cast devices found on network")
}
}
i := 1
},
}

// CastDevice represents a discovered Chromecast device
type CastDevice struct {
Device string
DeviceName string
AddrV4 string
Port int
UUID string
}

// performBroadSearch does a comprehensive search using both mDNS and port scanning
func performBroadSearch(iface *net.Interface, dnsTimeoutSeconds int) []CastDevice {
var allDevices []CastDevice
deviceMap := make(map[string]CastDevice) // Use UUID as key to deduplicate

// First, try mDNS discovery
outputInfo("Performing mDNS discovery...")
ctx, cancel := context.WithTimeout(context.Background(), time.Second*time.Duration(dnsTimeoutSeconds*3)) // Use 3x timeout for broad search
castEntryChan, err := castdns.DiscoverCastDNSEntries(ctx, iface)
if err == nil {
for d := range castEntryChan {
outputInfo("%d) device=%q device_name=%q address=\"%s:%d\" uuid=%q", i, d.Device, d.DeviceName, d.AddrV4, d.Port, d.UUID)
i++
device := CastDevice{
Device: d.Device,
DeviceName: d.DeviceName,
AddrV4: d.AddrV4.String(),
Port: d.Port,
UUID: d.UUID,
}
if device.UUID != "" {
deviceMap[device.UUID] = device
} else {
// If no UUID, use address:port as key
key := fmt.Sprintf("%s:%d", device.AddrV4, device.Port)
deviceMap[key] = device
}
}
if i == 1 {
outputError("no cast devices found on network")
}
cancel()

outputInfo("Found %d devices via mDNS, performing port scan to find additional devices...", len(deviceMap))

// Then, do a targeted port scan on the local subnet
if localSubnet, err := detectLocalSubnet(""); err == nil {
ipRange, err := ipaddr.NewIPAddressString(localSubnet).ToSequentialRange()
if err == nil {
// Use a smaller set of ports for ls to keep it reasonably fast
ports := []int{8009, 8008, 8443, 32236} // Common ports + known group port

var wg sync.WaitGroup
ipCh := make(chan *ipaddr.IPAddress, 100)

// Send IPs to scan
go func() {
it := ipRange.Iterator()
for it.HasNext() {
ip := it.Next()
ipCh <- ip
}
close(ipCh)
}()

// Scan IPs in parallel
for i := 0; i < 20; i++ { // Use fewer goroutines than scan command
wg.Add(1)
go func() {
defer wg.Done()
dialer := &net.Dialer{
Timeout: 300 * time.Millisecond,
}
for ip := range ipCh {
for _, port := range ports {
conn, err := dialer.Dial("tcp", fmt.Sprintf("%v:%d", ip, port))
if err != nil {
continue
}
conn.Close()

// Try to get device info
if info, err := application.GetInfo(ip.String()); err == nil {
device := CastDevice{
Device: "Unknown Device",
DeviceName: info.Name,
AddrV4: ip.String(),
Port: port,
UUID: "", // Port scan doesn't give us UUID
}

// Use address:port as key since we don't have UUID from port scan
key := fmt.Sprintf("%s:%d", device.AddrV4, device.Port)

// Only add if we haven't seen this device yet
if _, exists := deviceMap[key]; !exists {
// Also check if we have this device by name on a different port
found := false
for _, existing := range deviceMap {
if existing.DeviceName == device.DeviceName && existing.AddrV4 == device.AddrV4 {
found = true
break
}
}
if !found {
deviceMap[key] = device
}
}
}
}
}
}()
}
wg.Wait()
}
},
}

// Convert map to slice and sort
for _, device := range deviceMap {
allDevices = append(allDevices, device)
}

// Sort by device name for consistent output
sort.Slice(allDevices, func(i, j int) bool {
return allDevices[i].DeviceName < allDevices[j].DeviceName
})

return allDevices
}

func init() {
Expand Down
2 changes: 1 addition & 1 deletion cmd/mute.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ var muteCmd = &cobra.Command{
Use: "mute",
Short: "Mute the chromecast",
Run: func(cmd *cobra.Command, args []string) {
app, err := castApplication(cmd, args)
app, err := castApplication(cmd, args)
if err != nil {
exit("unable to get cast application: %v", err)
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/next.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ var nextCmd = &cobra.Command{
Use: "next",
Short: "Play the next available media",
Run: func(cmd *cobra.Command, args []string) {
app, err := castApplication(cmd, args)
app, err := castApplication(cmd, args)
if err != nil {
exit("unable to get cast application: %v", err)
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/pause.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ var pauseCmd = &cobra.Command{
Use: "pause",
Short: "Pause the currently playing media on the chromecast",
Run: func(cmd *cobra.Command, args []string) {
app, err := castApplication(cmd, args)
app, err := castApplication(cmd, args)
if err != nil {
exit("unable to get cast application: %v", err)
}
Expand Down
Loading