Files
Bose-SoundTouch/pkg/discovery/mediaserver.go
T
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

181 lines
4.5 KiB
Go

package discovery
import (
"context"
"log/slog"
"sync"
"time"
)
const (
mediaServerDeviceType = "urn:schemas-upnp-org:device:MediaServer:1"
cdsServiceType = "urn:schemas-upnp-org:service:ContentDirectory:1"
// descFetchTimeout is a separate budget for description fetches so the
// overall SSDP sweep timing does not cut them off.
descFetchTimeout = 8 * time.Second
)
// MediaServer is a discovered DLNA UPnP MediaServer that exposes a
// ContentDirectory service.
type MediaServer struct {
// UDN is the stable unique device name (uuid:...) from the UPnP description.
UDN string
// FriendlyName is the human-readable device name, e.g. "FRITZ!Box 7590".
FriendlyName string
// Manufacturer and ModelName let callers show a useful device subtitle.
Manufacturer string
ModelName string
// Address is the "host:port" of the device description endpoint.
Address string
// CDSControlURL is the fully resolved URL for ContentDirectory SOAP actions.
// Empty string means the device does not expose ContentDirectory.
CDSControlURL string
// IconURL is the first icon the device advertised, resolved to absolute form.
IconURL string
}
// DiscoverMediaServers sends SSDP M-SEARCH requests for MediaServer devices,
// fetches each device description concurrently, and returns only the servers
// that expose a ContentDirectory service. Deduplicated by UDN.
func DiscoverMediaServers(ctx context.Context, timeout time.Duration) ([]MediaServer, error) {
if timeout <= 0 {
timeout = defaultTimeout
}
opts := SearchOptions{
Targets: []string{
mediaServerDeviceType,
"ssdp:all",
},
Timeout: timeout,
}
responses, err := SearchSSDP(ctx, opts)
if err != nil {
return nil, err
}
if len(responses) == 0 {
return nil, nil
}
// Fetch descriptions concurrently. Use a fresh context so that the
// description fetches are not cut off by the already-elapsed SSDP timeout.
fctx, fcancel := context.WithTimeout(ctx, descFetchTimeout)
defer fcancel()
type fetchResult struct {
srv MediaServer
ok bool
}
results := make(chan fetchResult, len(responses))
var wg sync.WaitGroup
for _, resp := range responses {
wg.Add(1)
go func(loc string) {
defer wg.Done()
desc, err := FetchDescription(fctx, loc)
if err != nil {
slog.Warn("mediaserver: description fetch failed", "location", loc, "err", err.Error())
results <- fetchResult{}
return
}
srv, ok := mediaServerFromDescription(desc)
results <- fetchResult{srv: srv, ok: ok}
}(resp.Location)
}
wg.Wait()
close(results)
seen := map[string]struct{}{}
var out []MediaServer
for r := range results {
if !r.ok || r.srv.CDSControlURL == "" || r.srv.UDN == "" {
continue
}
if _, dup := seen[r.srv.UDN]; dup {
continue
}
seen[r.srv.UDN] = struct{}{}
out = append(out, r.srv)
}
return out, nil
}
// mediaServerFromDescription maps a parsed Description to a MediaServer.
// Returns ok=false when the description does not expose a ContentDirectory
// service (i.e. the device is not a usable DLNA media server).
//
// It walks the device tree so that nested MediaServer sub-devices (e.g.
// FRITZ!Box root device nesting the NAS MediaServer) are found correctly.
func mediaServerFromDescription(desc *Description) (MediaServer, bool) {
if desc == nil {
return MediaServer{}, false
}
svc, ok := desc.FindService(cdsServiceType)
if !ok || svc.ControlURL == "" {
return MediaServer{}, false
}
srv := MediaServer{
UDN: desc.Root.UDN,
FriendlyName: desc.Root.FriendlyName,
Manufacturer: desc.Root.Manufacturer,
ModelName: desc.Root.ModelName,
CDSControlURL: svc.ControlURL,
}
// Walk sub-devices to fill in UDN / FriendlyName if the root is sparse
// (some devices put it all in the sub-device, e.g. FRITZ!Box).
fillFromTree(desc, &srv)
if ic, ok := desc.FirstIcon(); ok {
srv.IconURL = ic.URL
}
return srv, true
}
// fillFromTree walks the description tree to fill in missing fields on srv
// from sub-devices. Only fills in fields that are still empty.
func fillFromTree(desc *Description, srv *MediaServer) {
walkDevice(&desc.Root, srv)
}
func walkDevice(dev *Device, srv *MediaServer) {
if srv.FriendlyName == "" && dev.FriendlyName != "" {
srv.FriendlyName = dev.FriendlyName
}
if srv.UDN == "" && dev.UDN != "" {
srv.UDN = dev.UDN
}
if srv.Manufacturer == "" && dev.Manufacturer != "" {
srv.Manufacturer = dev.Manufacturer
}
if srv.ModelName == "" && dev.ModelName != "" {
srv.ModelName = dev.ModelName
}
for i := range dev.Devices {
walkDevice(&dev.Devices[i], srv)
}
}