diff --git a/backend/internal/discovery/discovery.go b/backend/internal/discovery/discovery.go new file mode 100644 index 0000000..011223b --- /dev/null +++ b/backend/internal/discovery/discovery.go @@ -0,0 +1,129 @@ +// Package discovery finds Apollo/Artemis hosts on the network via mDNS. +// Apollo advertises itself as _nvstream._tcp.local — the same service +// type that Moonlight scans for. +package discovery + +import ( + "context" + "log" + "sync" + "time" + + "github.com/grandcat/zeroconf" +) + +const nvstreamService = "_nvstream._tcp" + +// Host represents a discovered Apollo/Artemis streaming host. +type Host struct { + Name string `json:"name"` + IP string `json:"ip"` + Port int `json:"port"` + Online bool `json:"online"` + SeenAt time.Time `json:"seen_at"` +} + +// Discoverer watches the local (Tailscale) network for Apollo hosts. +type Discoverer struct { + mu sync.RWMutex + hosts map[string]*Host // keyed by name +} + +// New creates a new Discoverer with an empty host list. +func New() *Discoverer { + return &Discoverer{hosts: make(map[string]*Host)} +} + +// Run starts mDNS browsing and blocks until ctx is cancelled. +// Discovered hosts are stored and retrievable via Hosts(). +func (d *Discoverer) Run(ctx context.Context) { + for { + if err := d.browse(ctx); err != nil && ctx.Err() == nil { + log.Printf("discovery: browse error: %v — retrying in 10s", err) + select { + case <-ctx.Done(): + return + case <-time.After(10 * time.Second): + } + } + if ctx.Err() != nil { + return + } + } +} + +func (d *Discoverer) browse(ctx context.Context) error { + resolver, err := zeroconf.NewResolver(nil) + if err != nil { + return err + } + + entries := make(chan *zeroconf.ServiceEntry) + browseCtx, cancel := context.WithCancel(ctx) + defer cancel() + + go func() { + for entry := range entries { + ip := "" + if len(entry.AddrIPv4) > 0 { + ip = entry.AddrIPv4[0].String() + } else if len(entry.AddrIPv6) > 0 { + ip = entry.AddrIPv6[0].String() + } + if ip == "" { + continue + } + h := &Host{ + Name: entry.ServiceInstanceName(), + IP: ip, + Port: entry.Port, + Online: true, + SeenAt: time.Now(), + } + d.upsert(h) + log.Printf("discovery: found host %s at %s:%d", h.Name, h.IP, h.Port) + } + }() + + if err := resolver.Browse(browseCtx, nvstreamService, "local.", entries); err != nil { + return err + } + <-browseCtx.Done() + return nil +} + +func (d *Discoverer) upsert(h *Host) { + d.mu.Lock() + defer d.mu.Unlock() + d.hosts[h.Name] = h +} + +// Hosts returns a snapshot of all known hosts. +func (d *Discoverer) Hosts() []*Host { + d.mu.RLock() + defer d.mu.RUnlock() + + // Mark hosts as offline if not seen in 30s + cutoff := time.Now().Add(-30 * time.Second) + out := make([]*Host, 0, len(d.hosts)) + for _, h := range d.hosts { + copy := *h + if h.SeenAt.Before(cutoff) { + copy.Online = false + } + out = append(out, ©) + } + return out +} + +// AddManual adds a host by IP/port without mDNS (for hosts behind NAT or +// on subnets that don't support multicast). +func (d *Discoverer) AddManual(name, ip string, port int) { + d.upsert(&Host{ + Name: name, + IP: ip, + Port: port, + Online: true, + SeenAt: time.Now(), + }) +}