diff --git a/go.mod b/go.mod index 031dbc6..7212164 100644 --- a/go.mod +++ b/go.mod @@ -37,6 +37,7 @@ require ( github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/cenkalti/backoff v2.2.1+incompatible // indirect github.com/chromedp/sysutil v1.1.0 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/stringish v0.1.1 // 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,6 +78,7 @@ 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/net v0.57.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect gopkg.in/ini.v1 v1.67.3 // indirect diff --git a/internal/device/chromecast.go b/internal/device/chromecast.go index a2ebb36..87535cc 100644 --- a/internal/device/chromecast.go +++ b/internal/device/chromecast.go @@ -1,11 +1,16 @@ package device import ( + "cmp" "context" "fmt" + "log/slog" + "net" "net/url" + "strconv" "github.com/vishen/go-chromecast/application" + castdns "github.com/vishen/go-chromecast/dns" "github.com/stupside/castor/internal/media" ) @@ -16,19 +21,89 @@ type chromecastDevice struct { app *application.Application } +var _ Device = (*chromecastDevice)(nil) + // connectChromecast opens the cast application channel. The underlying // library's Start has no context support, so cancellation cannot interrupt -// the dial. +// the dial. Address may be a bare host (default port 8009) or host:port as +// produced by discovery; cast groups advertise on non-default ports. func connectChromecast(info Info) (Device, error) { + host := info.Address + port := chromecastPort + if h, p, err := net.SplitHostPort(info.Address); err == nil { + if n, err := strconv.Atoi(p); err == nil { + host, port = h, n + } + } + app := application.NewApplication( application.WithCacheDisabled(true), ) - if err := app.Start(info.Address, chromecastPort); err != nil { + if err := app.Start(host, port); err != nil { return nil, fmt.Errorf("connecting to chromecast: %w", err) } return &chromecastDevice{app: app}, nil } +// discoverChromecast browses mDNS (_googlecast._tcp) for cast devices until +// ctx expires. The entries channel is closed by the library when ctx is done, +// so ranging over it consumes the full discovery window. +func discoverChromecast(ctx context.Context) []Info { + entries, err := castdns.DiscoverCastDNSEntries(ctx, nil) + if err != nil { + slog.WarnContext(ctx, "chromecast discovery error", "error", err) + return nil + } + + var devices []Info + seen := make(map[string]struct{}) + for entry := range entries { + info, ok := chromecastInfo(entry) + if !ok { + continue + } + + // mDNS re-announces the same device; dedupe by UUID, falling back + // to the resolved address when the entry carries no UUID. + key := cmp.Or(entry.UUID, info.Address) + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + + devices = append(devices, info) + } + return devices +} + +// chromecastInfo maps an mDNS cast entry to a device Info, reporting false when +// the entry advertises no usable IP address. IPv4 is preferred over IPv6, and +// the friendly DeviceName over the mDNS instance and host names. A non-default +// port is preserved as host:port so cast groups, which advertise on random high +// ports rather than 8009, stay connectable; a bare host implies port 8009. +func chromecastInfo(entry castdns.CastEntry) (Info, bool) { + var host string + switch { + case entry.AddrV4 != nil: + host = entry.AddrV4.String() + case entry.AddrV6 != nil: + host = entry.AddrV6.String() + default: + return Info{}, false + } + + address := host + if entry.Port > 0 && entry.Port != chromecastPort { + address = net.JoinHostPort(host, strconv.Itoa(entry.Port)) + } + + return Info{ + Name: cmp.Or(entry.DeviceName, entry.Name, entry.Host), + Type: TypeChromecast, + Address: address, + }, true +} + func (c *chromecastDevice) Play(_ context.Context, streamURL *url.URL, contentType string) error { if err := c.app.Load(streamURL.String(), 0, contentType, false, true, true); err != nil { return fmt.Errorf("starting chromecast playback: %w", err) diff --git a/internal/device/chromecast_test.go b/internal/device/chromecast_test.go new file mode 100644 index 0000000..eafd47b --- /dev/null +++ b/internal/device/chromecast_test.go @@ -0,0 +1,59 @@ +package device + +import ( + "net" + "testing" + + castdns "github.com/vishen/go-chromecast/dns" +) + +func TestChromecastInfo(t *testing.T) { + tests := []struct { + name string + entry castdns.CastEntry + want Info + ok bool + }{ + { + name: "ipv4 with friendly name on default port", + entry: castdns.CastEntry{DeviceName: "Office Display", AddrV4: net.ParseIP("192.0.2.10"), Port: chromecastPort}, + want: Info{Name: "Office Display", Type: TypeChromecast, Address: "192.0.2.10"}, + ok: true, + }, + { + name: "cast group on non-default port keeps host:port", + entry: castdns.CastEntry{DeviceName: "Speakers", AddrV4: net.ParseIP("192.0.2.20"), Port: 32541}, + want: Info{Name: "Speakers", Type: TypeChromecast, Address: "192.0.2.20:32541"}, + ok: true, + }, + { + name: "ipv6 used when no ipv4 is advertised", + entry: castdns.CastEntry{DeviceName: "Living Room", AddrV6: net.ParseIP("2001:db8::1"), Port: chromecastPort}, + want: Info{Name: "Living Room", Type: TypeChromecast, Address: "2001:db8::1"}, + ok: true, + }, + { + name: "name falls back to the mDNS instance name", + entry: castdns.CastEntry{Name: "Kitchen", AddrV4: net.ParseIP("192.0.2.30"), Port: chromecastPort}, + want: Info{Name: "Kitchen", Type: TypeChromecast, Address: "192.0.2.30"}, + ok: true, + }, + { + name: "entry without an address is rejected", + entry: castdns.CastEntry{DeviceName: "Office Display"}, + ok: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := chromecastInfo(tt.entry) + if ok != tt.ok { + t.Fatalf("chromecastInfo() ok = %v, want %v", ok, tt.ok) + } + if ok && got != tt.want { + t.Errorf("chromecastInfo() = %+v, want %+v", got, tt.want) + } + }) + } +} diff --git a/internal/device/device.go b/internal/device/device.go index d504bf9..26b4ee6 100644 --- a/internal/device/device.go +++ b/internal/device/device.go @@ -7,12 +7,11 @@ package device import ( "context" "fmt" - "log/slog" "net/url" + "slices" "strings" + "sync" "time" - - "github.com/huin/goupnp" ) type Type string @@ -68,29 +67,19 @@ 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 renderers: DLNA via SSDP and Chromecast +// via mDNS (_googlecast._tcp). Both scans run in parallel and share the same +// timeout window; a protocol that fails contributes no devices rather than +// failing the whole scan. func Discover(ctx context.Context, timeout time.Duration) ([]Info, error) { ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - 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 - } + var dlna, chromecast []Info + var wg sync.WaitGroup + wg.Go(func() { dlna = discoverDLNA(ctx) }) + wg.Go(func() { chromecast = discoverChromecast(ctx) }) + wg.Wait() - devices := make([]Info, 0, len(results)) - for _, r := range results { - if r.Root == nil { - continue - } - devices = append(devices, Info{ - Name: r.Root.Device.FriendlyName, - Type: TypeDLNA, - Address: r.Location.String(), - }) - } - return devices, nil + return slices.Concat(dlna, chromecast), nil } diff --git a/internal/device/dlna.go b/internal/device/dlna.go index 0da28e4..b0c8cd8 100644 --- a/internal/device/dlna.go +++ b/internal/device/dlna.go @@ -1,6 +1,7 @@ package device import ( + "cmp" "context" "fmt" "log/slog" @@ -18,6 +19,51 @@ type dlnaDevice struct { transport *av1.AVTransport1 } +var _ Device = (*dlnaDevice)(nil) + +// discoverDLNA browses SSDP for UPnP MediaRenderer devices until ctx expires. +func discoverDLNA(ctx context.Context) []Info { + 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 + } + + var devices []Info + seen := make(map[string]struct{}) + for _, result := range results { + info, ok := dlnaInfo(result) + if !ok { + continue + } + + // SSDP re-announces the same device; dedupe by USN, falling back + // to the resolved address when the announcement carries no USN. + key := cmp.Or(result.USN, info.Address) + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + + devices = append(devices, info) + } + return devices +} + +// dlnaInfo maps a discovered UPnP root device to a device Info, reporting false +// when the announcement carries no reachable device to name. +func dlnaInfo(result goupnp.MaybeRootDevice) (Info, bool) { + if result.Root == nil || result.Location == nil { + return Info{}, false + } + + return Info{ + Name: result.Root.Device.FriendlyName, + Type: TypeDLNA, + Address: result.Location.String(), + }, true +} + func connectDLNA(ctx context.Context, info Info) (Device, error) { u, err := url.Parse(info.Address) if err != nil { diff --git a/internal/device/dlna_test.go b/internal/device/dlna_test.go new file mode 100644 index 0000000..c2343d5 --- /dev/null +++ b/internal/device/dlna_test.go @@ -0,0 +1,59 @@ +package device + +import ( + "net/url" + "testing" + + "github.com/huin/goupnp" +) + +func TestDLNAInfo(t *testing.T) { + rootDevice := func(name string) *goupnp.RootDevice { + return &goupnp.RootDevice{Device: goupnp.Device{FriendlyName: name}} + } + location := func(host, path string) *url.URL { + return &url.URL{Scheme: "http", Host: host, Path: path} + } + + tests := []struct { + name string + result goupnp.MaybeRootDevice + want Info + ok bool + }{ + { + name: "named renderer with a location", + result: goupnp.MaybeRootDevice{Root: rootDevice("Living Room TV"), Location: location("192.0.2.10:8200", "/rootDesc.xml")}, + want: Info{Name: "Living Room TV", Type: TypeDLNA, Address: "http://192.0.2.10:8200/rootDesc.xml"}, + ok: true, + }, + { + name: "second renderer maps independently", + result: goupnp.MaybeRootDevice{Root: rootDevice("Bedroom Speaker"), Location: location("192.0.2.20:49152", "/desc.xml")}, + want: Info{Name: "Bedroom Speaker", Type: TypeDLNA, Address: "http://192.0.2.20:49152/desc.xml"}, + ok: true, + }, + { + name: "announcement without a root device is rejected", + result: goupnp.MaybeRootDevice{Location: location("192.0.2.30:8200", "/desc.xml")}, + ok: false, + }, + { + name: "announcement without a location is rejected", + result: goupnp.MaybeRootDevice{Root: rootDevice("Ghost")}, + ok: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := dlnaInfo(tt.result) + if ok != tt.ok { + t.Fatalf("dlnaInfo() ok = %v, want %v", ok, tt.ok) + } + if ok && got != tt.want { + t.Errorf("dlnaInfo() = %+v, want %+v", got, tt.want) + } + }) + } +}