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..01f67f0 100644 --- a/internal/device/chromecast.go +++ b/internal/device/chromecast.go @@ -3,9 +3,13 @@ package device import ( "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" ) @@ -18,17 +22,80 @@ type chromecastDevice struct { // 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 { + var host string + switch { + case entry.AddrV4 != nil: + host = entry.AddrV4.String() + case entry.AddrV6 != nil: + host = entry.AddrV6.String() + default: + continue + } + + key := entry.UUID + if key == "" { + key = host + ":" + strconv.Itoa(entry.Port) + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + + name := entry.DeviceName + if name == "" { + name = entry.Name + } + if name == "" { + name = entry.Host + } + + address := host + if entry.Port > 0 && entry.Port != chromecastPort { + address = net.JoinHostPort(host, strconv.Itoa(entry.Port)) + } + + devices = append(devices, Info{ + Name: name, + Type: TypeChromecast, + Address: address, + }) + } + return devices +} + 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/device.go b/internal/device/device.go index d504bf9..be602b0 100644 --- a/internal/device/device.go +++ b/internal/device/device.go @@ -10,6 +10,7 @@ import ( "log/slog" "net/url" "strings" + "sync" "time" "github.com/huin/goupnp" @@ -68,17 +69,44 @@ 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. func Discover(ctx context.Context, timeout time.Duration) ([]Info, error) { ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() + var ( + wg sync.WaitGroup + mu sync.Mutex + devices []Info + ) + collect := func(found []Info) { + mu.Lock() + devices = append(devices, found...) + mu.Unlock() + } + + wg.Add(2) + go func() { + defer wg.Done() + collect(discoverDLNA(ctx)) + }() + go func() { + defer wg.Done() + collect(discoverChromecast(ctx)) + }() + wg.Wait() + + return devices, nil +} + +// discoverDLNA scans for UPnP MediaRenderer devices via SSDP. +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, nil + return nil } devices := make([]Info, 0, len(results)) @@ -92,5 +120,5 @@ func Discover(ctx context.Context, timeout time.Duration) ([]Info, error) { Address: r.Location.String(), }) } - return devices, nil + return devices }