Skip to content
Closed
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
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -53,13 +54,15 @@ 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
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
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
Expand All @@ -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
Expand Down
71 changes: 69 additions & 2 deletions internal/device/chromecast.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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)
Expand Down
38 changes: 33 additions & 5 deletions internal/device/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"log/slog"
"net/url"
"strings"
"sync"
"time"

"github.com/huin/goupnp"
Expand Down Expand Up @@ -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))
Expand All @@ -92,5 +120,5 @@ func Discover(ctx context.Context, timeout time.Duration) ([]Info, error) {
Address: r.Location.String(),
})
}
return devices, nil
return devices
}