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>
165 lines
4.7 KiB
Go
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))
|
|
}
|