moonlight-relay-server/backend/internal/discovery/discovery.go

130 lines
2.9 KiB
Go
Raw Normal View History

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