Skip to content
Merged
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
79 changes: 77 additions & 2 deletions internal/device/chromecast.go
Original file line number Diff line number Diff line change
@@ -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"
)
Expand All @@ -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)
Expand Down
59 changes: 59 additions & 0 deletions internal/device/chromecast_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
35 changes: 12 additions & 23 deletions internal/device/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,11 @@ package device
import (
"context"
"fmt"
"log/slog"
"net/url"
"slices"
"strings"
"sync"
"time"

"github.com/huin/goupnp"
)

type Type string
Expand Down Expand Up @@ -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
}
46 changes: 46 additions & 0 deletions internal/device/dlna.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package device

import (
"cmp"
"context"
"fmt"
"log/slog"
Expand All @@ -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 {
Expand Down
59 changes: 59 additions & 0 deletions internal/device/dlna_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}