Files
Tobias GesellchenandClaude Opus 4.8 ad0f1fbd8f feat(discovery,dlna): generic SSDP core + media-server discovery + browse client
Foundation for browsing DLNA media servers and playing tracks on a
SoundTouch speaker (https://github.com/gesellix/Bose-SoundTouch/discussions/213).

- pkg/discovery/ssdp.go: a target-agnostic UPnP SSDP core. SearchSSDP sweeps
  multiple targets (a typed device URN plus ssdp:all, since some servers only
  answer one), fans out across all routable IPv4 interfaces, and sends each
  batch in two rounds spaced 80ms apart so slower NAS/router boxes that drop
  back-to-back bursts still answer. FetchDescription parses a UPnP device
  description into a generic device tree with FindService/FirstIcon that
  recurse through sub-devices. The XML parse is a pure function for offline
  unit testing.
- pkg/discovery/mediaserver.go: DiscoverMediaServers rides the core, keeps
  only devices exposing a ContentDirectory service, and dedupes by UDN. The
  description->MediaServer mapping is a pure, tested function.
- pkg/dlna: a ContentDirectory browse client (Browse + DIDL-Lite parse +
  IsAudioItem), consuming discovery.MediaServer. Kept separate from discovery,
  mirroring how pkg/client is separate from pkg/discovery. Track metadata maps
  upnp:artist / upnp:album; the audio filter accepts audio/* MIME or an
  audioItem/musicTrack class.

Existing SoundTouch speaker discovery (pkg/discovery/upnp.go) is untouched;
migrating it onto the shared core is a later, de-risked step. Tests cover the
description/DIDL parsers and run the browse client against an in-process
ContentDirectory server; the parse was checked against real minidlna and
FRITZ!Box output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:50:49 +02:00

519 lines
13 KiB
Go

// Package discovery provides device discovery functionality for Bose SoundTouch
// devices using mDNS and UPnP protocols.
package discovery
import (
"bytes"
"context"
"encoding/xml"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// SearchOptions configures a generic SSDP M-SEARCH sweep.
// Targets lists the ST values to search for (e.g.
// "urn:schemas-upnp-org:device:MediaServer:1" and "ssdp:all").
// Timeout is how long to listen for responses.
// Interface, when non-empty, pins multicast to that NIC by name;
// when empty, all non-loopback IPv4 interfaces are used.
type SearchOptions struct {
Targets []string
Timeout time.Duration
Interface string
}
// SSDPResponse holds the raw fields from one SSDP HTTP/1.1 200 OK response.
// Responses are deduped by Location before being returned by SearchSSDP.
type SSDPResponse struct {
Location string
USN string
ST string
Server string
}
// Description is the parsed content of a UPnP device description XML document.
type Description struct {
// URLBase is the base URL declared in the document (may be empty).
URLBase string
Root Device
}
// Device represents one UPnP device node (root or sub-device).
type Device struct {
DeviceType string
FriendlyName string
Manufacturer string
ModelName string
SerialNumber string
UDN string
Icons []Icon
Services []UPnPService
Devices []Device // embedded sub-devices (e.g. FRITZ!Box nests MediaServer)
}
// UPnPService is a single UPnP service advertisement inside a Device.
type UPnPService struct {
ServiceType string
ControlURL string
EventSubURL string
SCPDURL string
}
// Icon is one entry from a UPnP iconList.
type Icon struct {
MimeType string
Width int
Height int
URL string
}
// FindService walks the description tree (root device and all sub-devices) and
// returns the first UPnPService whose ServiceType equals serviceType.
func (d *Description) FindService(serviceType string) (UPnPService, bool) {
return findServiceInDevice(&d.Root, serviceType)
}
func findServiceInDevice(dev *Device, serviceType string) (UPnPService, bool) {
for _, svc := range dev.Services {
if svc.ServiceType == serviceType {
return svc, true
}
}
for i := range dev.Devices {
if svc, ok := findServiceInDevice(&dev.Devices[i], serviceType); ok {
return svc, true
}
}
return UPnPService{}, false
}
// FirstIcon walks the device tree depth-first and returns the first icon it
// finds (which is the icon advertised in the root device, or its first
// sub-device if the root has none).
func (d *Description) FirstIcon() (Icon, bool) {
return firstIconInDevice(&d.Root)
}
func firstIconInDevice(dev *Device) (Icon, bool) {
if len(dev.Icons) > 0 {
return dev.Icons[0], true
}
for i := range dev.Devices {
if ic, ok := firstIconInDevice(&dev.Devices[i]); ok {
return ic, true
}
}
return Icon{}, false
}
// ssdpDefaultMXSecs is the M-SEARCH MX header value (seconds the device may
// wait before answering). Keep it generous so slower NAS boxes are not missed.
const ssdpDefaultMXSecs = 3
// SearchSSDP sends SSDP M-SEARCH requests for each target in opts.Targets,
// collects responses until opts.Timeout expires, and returns the unique
// responses deduped by LOCATION. When opts.Interface is empty, the search is
// sent from every non-loopback IPv4 interface; when set, only that interface
// is used.
func SearchSSDP(ctx context.Context, opts SearchOptions) ([]SSDPResponse, error) {
if opts.Timeout <= 0 {
opts.Timeout = defaultTimeout
}
sctx, cancel := context.WithTimeout(ctx, opts.Timeout)
defer cancel()
mcAddr, err := net.ResolveUDPAddr("udp4", ssdpAddr)
if err != nil {
return nil, fmt.Errorf("ssdp: resolve multicast addr: %w", err)
}
// Build M-SEARCH packets for each target.
var msgs [][]byte
for _, st := range opts.Targets {
msgs = append(msgs, buildMSearchPacket(st))
}
// Determine which source IPs to send from.
var srcIPs []net.IP
if opts.Interface != "" {
ip, err := interfaceIPv4(opts.Interface)
if err != nil {
return nil, err
}
srcIPs = []net.IP{ip}
} else {
srcIPs = candidateIPv4Addrs()
if len(srcIPs) == 0 {
slog.Warn("ssdp: no usable IPv4 interfaces, falling back to wildcard")
srcIPs = []net.IP{net.IPv4zero}
}
}
slog.Info("ssdp: M-SEARCH starting",
"targets", opts.Targets,
"interfaces", len(srcIPs),
"timeout", opts.Timeout.String(),
)
// Collect unique locations across all goroutines.
mu := sync.Mutex{}
byLocation := map[string]SSDPResponse{}
var wg sync.WaitGroup
for _, srcIP := range srcIPs {
wg.Add(1)
go func(ip net.IP) {
defer wg.Done()
ssdpSendRecv(sctx, ip, mcAddr, msgs, func(resp SSDPResponse) {
mu.Lock()
defer mu.Unlock()
if _, exists := byLocation[resp.Location]; !exists {
byLocation[resp.Location] = resp
slog.Info("ssdp: new location", "location", resp.Location, "st", resp.ST)
}
})
}(srcIP)
}
wg.Wait()
out := make([]SSDPResponse, 0, len(byLocation))
for _, r := range byLocation {
out = append(out, r)
}
slog.Info("ssdp: M-SEARCH done", "locations", len(out))
return out, nil
}
// buildMSearchPacket returns an SSDP M-SEARCH request for the given ST value.
func buildMSearchPacket(st string) []byte {
return []byte(strings.Join([]string{
"M-SEARCH * HTTP/1.1",
"HOST: " + ssdpAddr,
"MAN: \"ssdp:discover\"",
fmt.Sprintf("MX: %d", ssdpDefaultMXSecs),
"ST: " + st,
"USER-AGENT: AfterTouch/1 UPnP/1.0",
"", "",
}, "\r\n"))
}
// ssdpSendRecv opens a UDP socket bound to srcIP, sends all msgs to mcAddr,
// reads responses until sctx is done or a UDP timeout, and calls notify for
// each response that carries a non-empty LOCATION header.
func ssdpSendRecv(sctx context.Context, srcIP net.IP, mcAddr *net.UDPAddr, msgs [][]byte, notify func(SSDPResponse)) {
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: srcIP, Port: 0})
if err != nil {
slog.Warn("ssdp: ListenUDP failed", "src", srcIP.String(), "err", err.Error())
return
}
defer func() { _ = conn.Close() }()
// Send all messages in 2 rounds with an 80 ms gap between rounds.
// The spacing lets slower NAS/router boxes that drop back-to-back bursts
// still answer, rather than sending the whole batch as one burst.
for range 2 {
for _, msg := range msgs {
if _, err := conn.WriteToUDP(msg, mcAddr); err != nil {
slog.Warn("ssdp: WriteToUDP failed", "src", srcIP.String(), "err", err.Error())
}
}
time.Sleep(80 * time.Millisecond)
}
deadline, ok := sctx.Deadline()
if ok {
_ = conn.SetReadDeadline(deadline)
}
buf := make([]byte, 4096)
for {
select {
case <-sctx.Done():
return
default:
}
n, _, err := conn.ReadFromUDP(buf)
if err != nil {
// Timeout or context done.
return
}
loc := ssdpHeaderValue(buf[:n], "LOCATION")
if loc == "" {
continue
}
notify(SSDPResponse{
Location: loc,
USN: ssdpHeaderValue(buf[:n], "USN"),
ST: ssdpHeaderValue(buf[:n], "ST"),
Server: ssdpHeaderValue(buf[:n], "SERVER"),
})
}
}
// ssdpHeaderValue finds the value of header in a raw SSDP UDP packet.
// Header matching is case-insensitive.
func ssdpHeaderValue(packet []byte, header string) string {
lines := bytes.Split(packet, []byte("\r\n"))
prefix := strings.ToLower(header) + ":"
for _, line := range lines {
if len(line) <= len(prefix) {
continue
}
if strings.EqualFold(string(line[:len(prefix)]), prefix) {
return strings.TrimSpace(string(line[len(prefix):]))
}
}
return ""
}
// candidateIPv4Addrs returns the routable IPv4 source addresses to send SSDP
// M-SEARCH from. Excludes loopback, link-local, and interfaces that are down.
// Rationale: a host with two Wi-Fi adapters on different networks needs to
// probe both.
func candidateIPv4Addrs() []net.IP {
var out []net.IP
ifaces, err := net.Interfaces()
if err != nil {
return out
}
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 {
continue
}
if iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, a := range addrs {
ipnet, ok := a.(*net.IPNet)
if !ok {
continue
}
ip4 := ipnet.IP.To4()
if ip4 == nil {
continue
}
if ip4.IsLoopback() || ip4.IsLinkLocalUnicast() || ip4.IsLinkLocalMulticast() {
continue
}
out = append(out, ip4)
}
}
return out
}
// interfaceIPv4 returns the first non-loopback IPv4 address of the named
// interface, or an error if not found.
func interfaceIPv4(ifaceName string) (net.IP, error) {
iface, err := net.InterfaceByName(ifaceName)
if err != nil {
return nil, fmt.Errorf("ssdp: interface %q not found: %w", ifaceName, err)
}
addrs, err := iface.Addrs()
if err != nil {
return nil, fmt.Errorf("ssdp: read addrs for %q: %w", ifaceName, err)
}
for _, a := range addrs {
ipnet, ok := a.(*net.IPNet)
if !ok {
continue
}
ip4 := ipnet.IP.To4()
if ip4 == nil || ip4.IsLoopback() {
continue
}
return ip4, nil
}
return nil, fmt.Errorf("ssdp: interface %q has no usable IPv4 address", ifaceName)
}
// FetchDescription fetches the UPnP device description at location and parses
// it into a Description tree. Relative URLs in the tree (controlURL, icon URL)
// are resolved to absolute form using URLBase or location as the base.
func FetchDescription(ctx context.Context, location string) (*Description, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, location, nil)
if err != nil {
return nil, fmt.Errorf("ssdp: build request for %s: %w", location, err)
}
client := &http.Client{Timeout: 8 * time.Second}
resp, err := client.Do(req)
if err != nil {
slog.Warn("ssdp: fetch description failed", "location", location, "err", err.Error())
return nil, fmt.Errorf("ssdp: fetch %s: %w", location, err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("ssdp: read body from %s: %w", location, err)
}
return parseDescription(body, location)
}
// parseDescription parses raw UPnP device description XML bytes and resolves
// relative URLs against the given location. It is a pure function (no I/O)
// so it can be unit-tested on canned bytes.
func parseDescription(body []byte, location string) (*Description, error) {
// Raw XML types that mirror the UPnP device description schema.
type xmlIcon struct {
MimeType string `xml:"mimetype"`
Width int `xml:"width"`
Height int `xml:"height"`
URL string `xml:"url"`
}
type xmlService struct {
ServiceType string `xml:"serviceType"`
ControlURL string `xml:"controlURL"`
EventSubURL string `xml:"eventSubURL"`
SCPDURL string `xml:"SCPDURL"`
}
// xmlDevice is defined as a named type so it can reference itself.
type xmlDevice struct {
DeviceType string `xml:"deviceType"`
FriendlyName string `xml:"friendlyName"`
Manufacturer string `xml:"manufacturer"`
ModelName string `xml:"modelName"`
SerialNumber string `xml:"serialNumber"`
UDN string `xml:"UDN"`
Icons []xmlIcon `xml:"iconList>icon"`
Services []xmlService `xml:"serviceList>service"`
SubDevices []xmlDevice `xml:"deviceList>device"`
}
type xmlRoot struct {
XMLName xml.Name `xml:"root"`
URLBase string `xml:"URLBase"`
Device xmlDevice `xml:"device"`
}
var root xmlRoot
if err := xml.Unmarshal(body, &root); err != nil {
return nil, fmt.Errorf("ssdp: parse description XML: %w", err)
}
// Determine base URL for resolving relative references.
baseURL, _ := url.Parse(location)
if root.URLBase != "" {
if u, err := url.Parse(root.URLBase); err == nil {
baseURL = u
}
}
// Recursive mapper from xmlDevice to Device.
var mapDevice func(xd xmlDevice) Device
mapDevice = func(xd xmlDevice) Device {
d := Device{
DeviceType: xd.DeviceType,
FriendlyName: xd.FriendlyName,
Manufacturer: xd.Manufacturer,
ModelName: xd.ModelName,
SerialNumber: xd.SerialNumber,
UDN: xd.UDN,
}
for _, xi := range xd.Icons {
d.Icons = append(d.Icons, Icon{
MimeType: xi.MimeType,
Width: xi.Width,
Height: xi.Height,
URL: absURL(baseURL, xi.URL),
})
}
for _, xs := range xd.Services {
d.Services = append(d.Services, UPnPService{
ServiceType: xs.ServiceType,
ControlURL: absURL(baseURL, xs.ControlURL),
EventSubURL: absURL(baseURL, xs.EventSubURL),
SCPDURL: absURL(baseURL, xs.SCPDURL),
})
}
for i := range xd.SubDevices {
d.Devices = append(d.Devices, mapDevice(xd.SubDevices[i]))
}
return d
}
desc := &Description{
URLBase: root.URLBase,
Root: mapDevice(root.Device),
}
return desc, nil
}
// absURL resolves ref relative to base. If ref is already absolute, or if
// parsing fails, ref is returned unchanged.
func absURL(base *url.URL, ref string) string {
if ref == "" || base == nil {
return ref
}
u, err := url.Parse(ref)
if err != nil {
return ref
}
return base.ResolveReference(u).String()
}