tsplay/internal/discovery/discovery.go
28allday 8ee58b905f Initial commit: tuistream-play (tsplay) v0.1
Terminal Jellyfin client, watch-side companion to TUISTREAM.

- LAN auto-discovery (broadcast + unicast subnet sweep)
- mpv-window video playback over JSON IPC (pause/seek/progress/resume)
- headless audio playback with in-terminal now-playing screen
- volume control with remembered level, mute
- audio auto-advance + n/p track skip through the album queue
- Omarchy-themed UI with Nerd Font (nf-md) icons
- cliamp Jellyfin provider auto-config on login
- pacman dependency-installing installer

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:36:49 +01:00

165 lines
4.7 KiB
Go

// Package discovery finds Jellyfin servers on the local network using
// Jellyfin's built-in UDP auto-discovery: the client sends the literal string
// "who is JellyfinServer?" to port 7359 and every server replies with a small
// JSON document describing itself. This lets tsplay pre-fill the server URL so
// the user never has to type it (works for any Jellyfin on the LAN, not just
// TUISTREAM-deployed ones).
//
// We probe two ways and read replies on a single socket:
//
// - Broadcast: 255.255.255.255 and each interface's directed broadcast.
// - Unicast sweep: every host address in each interface's IPv4 subnet.
//
// The unicast sweep matters because many home networks (especially over WiFi)
// silently drop directed broadcasts, so a broadcast-only probe finds nothing
// even when the server's discovery port is open and reachable by unicast.
package discovery
import (
"encoding/json"
"net"
"time"
)
// port is Jellyfin's fixed auto-discovery UDP port.
const port = 7359
// query is the exact probe string Jellyfin servers listen for.
const query = "who is JellyfinServer?"
// maxSweep caps how many unicast hosts we probe per interface, so an unusually
// large subnet (e.g. a /16) can't blow up into 65k packets. A typical /24 home
// network is 254 hosts, well under this.
const maxSweep = 1024
// Server is one discovered Jellyfin instance.
type Server struct {
Name string `json:"Name"`
Address string `json:"Address"` // e.g. http://192.168.1.45:8096
ID string `json:"Id"`
}
// Discover sends probes and collects replies until timeout elapses. Results are
// de-duplicated by server Id. A nil slice (no error) simply means nothing
// answered — the caller falls back to manual entry.
func Discover(timeout time.Duration) ([]Server, error) {
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
if err != nil {
return nil, err
}
defer conn.Close()
payload := []byte(query)
for _, t := range probeTargets() {
_, _ = conn.WriteToUDP(payload, &net.UDPAddr{IP: t, Port: port})
}
_ = conn.SetReadDeadline(time.Now().Add(timeout))
seen := make(map[string]bool)
var out []Server
buf := make([]byte, 8192)
for {
n, _, err := conn.ReadFromUDP(buf)
if err != nil {
break // deadline reached or socket closed
}
var s Server
if json.Unmarshal(buf[:n], &s) != nil || s.Address == "" {
continue
}
key := s.ID
if key == "" {
key = s.Address
}
if seen[key] {
continue
}
seen[key] = true
out = append(out, s)
}
return out, nil
}
// probeTargets returns every IP we should send a probe to: the global
// broadcast, each interface's directed broadcast, and a unicast sweep of each
// interface's IPv4 subnet.
func probeTargets() []net.IP {
targets := []net.IP{net.IPv4bcast}
ifaces, err := net.Interfaces()
if err != nil {
return targets
}
for _, ifc := range ifaces {
if ifc.Flags&net.FlagUp == 0 || ifc.Flags&net.FlagLoopback != 0 {
continue
}
addrs, _ := ifc.Addrs()
for _, a := range addrs {
n, ok := a.(*net.IPNet)
if !ok {
continue
}
ip := n.IP.To4()
if ip == nil || ip.IsLoopback() {
continue
}
// Directed broadcast for this subnet (if the iface supports it).
if ifc.Flags&net.FlagBroadcast != 0 {
targets = append(targets, directedBroadcast(ip, n.Mask))
}
// Unicast sweep of every other host on this subnet.
targets = append(targets, sweepHosts(ip, n.Mask, maxSweep)...)
}
}
return targets
}
// directedBroadcast returns the all-ones host address for ip's subnet.
func directedBroadcast(ip net.IP, mask net.IPMask) net.IP {
b := make(net.IP, 4)
for i := 0; i < 4; i++ {
b[i] = ip[i] | ^mask[i]
}
return b
}
// sweepHosts enumerates the usable host addresses in ip's subnet, excluding the
// network address, the broadcast address, and ip itself. It returns at most max
// addresses; subnets larger than that are skipped entirely (no partial sweep,
// which would silently miss hosts).
func sweepHosts(ip net.IP, mask net.IPMask, max int) []net.IP {
ones, bits := mask.Size()
if bits != 32 {
return nil
}
hostCount := 1<<(uint(bits-ones)) - 2 // minus network + broadcast
if hostCount <= 0 || hostCount > max {
return nil
}
net4 := make(net.IP, 4)
bcast := make(net.IP, 4)
for i := 0; i < 4; i++ {
net4[i] = ip[i] & mask[i]
bcast[i] = ip[i] | ^mask[i]
}
start := ipToU32(net4) + 1
end := ipToU32(bcast) // exclusive
self := ipToU32(ip.To4())
var out []net.IP
for v := start; v < end; v++ {
if v == self {
continue
}
out = append(out, u32ToIP(v))
}
return out
}
func ipToU32(ip net.IP) uint32 {
ip = ip.To4()
return uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(ip[2])<<8 | uint32(ip[3])
}
func u32ToIP(v uint32) net.IP {
return net.IPv4(byte(v>>24), byte(v>>16), byte(v>>8), byte(v))
}