// 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(), }) }