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