129 lines
2.9 KiB
Go
129 lines
2.9 KiB
Go
// 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(),
|
|
})
|
|
}
|