diff --git a/go.mod b/go.mod index 031dbc6..6b376cf 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +33,7 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/buger/jsonparser v1.2.0 // indirect + github.com/cenkalti/backoff v2.2.1+incompatible // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect @@ -53,6 +54,7 @@ require ( github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/ws v1.4.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/grandcat/zeroconf v1.0.0 // indirect github.com/h2non/filetype v1.1.3 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -60,6 +62,7 @@ require ( github.com/mattn/go-isatty v0.0.23 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect + github.com/miekg/dns v1.1.62 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect @@ -75,7 +78,10 @@ require ( golang.org/x/crypto v0.54.0 // indirect golang.org/x/exp v0.0.0-20260718201538-764159d718ef // indirect golang.org/x/image v0.44.0 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/net v0.57.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.48.0 // indirect gopkg.in/ini.v1 v1.67.3 // indirect ) diff --git a/internal/device/device.go b/internal/device/device.go index d504bf9..ea2a6a5 100644 --- a/internal/device/device.go +++ b/internal/device/device.go @@ -13,6 +13,7 @@ import ( "time" "github.com/huin/goupnp" + castdns "github.com/vishen/go-chromecast/dns" ) type Type string @@ -68,17 +69,43 @@ func FindInfo(ctx context.Context, timeout time.Duration, dtype Type, name strin return Info{}, fmt.Errorf("device %q (type %s) not found", name, dtype) } -// Discover scans the local network for DLNA renderers. (Chromecast discovery -// requires mDNS which the current dependency set doesn't ship; callers -// connect to known Chromecast addresses directly.) +// Discover scans the local network for DLNA and Chromecast renderers. func Discover(ctx context.Context, timeout time.Duration) ([]Info, error) { ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() + type result struct { + kind Type + devices []Info + err error + } + results := make(chan result, 2) + + go func() { + devices, err := discoverDLNA(ctx) + results <- result{kind: TypeDLNA, devices: devices, err: err} + }() + go func() { + devices, err := discoverChromecasts(ctx) + results <- result{kind: TypeChromecast, devices: devices, err: err} + }() + + var devices []Info + for range 2 { + result := <-results + if result.err != nil { + slog.WarnContext(ctx, "device discovery error", "type", result.kind, "error", result.err) + continue + } + devices = append(devices, result.devices...) + } + return devices, nil +} + +func discoverDLNA(ctx context.Context) ([]Info, error) { results, err := goupnp.DiscoverDevicesCtx(ctx, "urn:schemas-upnp-org:device:MediaRenderer:1") if err != nil { - slog.WarnContext(ctx, "DLNA discovery error", "error", err) - return nil, nil + return nil, err } devices := make([]Info, 0, len(results)) @@ -94,3 +121,31 @@ func Discover(ctx context.Context, timeout time.Duration) ([]Info, error) { } return devices, nil } + +func discoverChromecasts(ctx context.Context) ([]Info, error) { + entries, err := castdns.DiscoverCastDNSEntries(ctx, nil) + if err != nil { + return nil, err + } + + var devices []Info + for entry := range entries { + info, ok := chromecastInfo(entry) + if !ok { + continue + } + devices = append(devices, info) + } + return devices, nil +} + +func chromecastInfo(entry castdns.CastEntry) (Info, bool) { + if entry.DeviceName == "" || entry.AddrV4 == nil { + return Info{}, false + } + return Info{ + Name: entry.DeviceName, + Type: TypeChromecast, + Address: entry.AddrV4.String(), + }, true +} diff --git a/internal/device/device_test.go b/internal/device/device_test.go new file mode 100644 index 0000000..c48d320 --- /dev/null +++ b/internal/device/device_test.go @@ -0,0 +1,30 @@ +package device + +import ( + "net" + "testing" + + castdns "github.com/vishen/go-chromecast/dns" +) + +func TestChromecastInfo(t *testing.T) { + entry := castdns.CastEntry{ + DeviceName: "Office Display", + AddrV4: net.ParseIP("192.0.2.10"), + } + + got, ok := chromecastInfo(entry) + if !ok { + t.Fatal("chromecastInfo() rejected a complete entry") + } + want := Info{Name: "Office Display", Type: TypeChromecast, Address: "192.0.2.10"} + if got != want { + t.Errorf("chromecastInfo() = %+v, want %+v", got, want) + } +} + +func TestChromecastInfoRejectsIncompleteEntry(t *testing.T) { + if _, ok := chromecastInfo(castdns.CastEntry{DeviceName: "Office Display"}); ok { + t.Error("chromecastInfo() accepted an entry without an IPv4 address") + } +}