mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
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>
181 lines
4.5 KiB
Go
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)
|
|
}
|
|
}
|