mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-10 20:56:14 +00:00
The DLNA UDN from discovery carries a "uuid:" prefix (e.g. uuid:fa095ecc-...), but a SoundTouch STORED_MUSIC account is the bare UUID plus /0 (the speaker's /sources reports the bare form). The mismatch made the player Library tab show an "Add" button for an already-registered server, and an Add via the UI would have registered a wrong "uuid:.../0" account. Normalize (strip "uuid:") when mapping discovery results to the DTO and when building the account in HandleAddLibraryServer, so the LAN list and the registered list agree and Add builds the correct account. Verified live: discover now returns the bare UDN, matching /sources. Also populate the previously-unset MediaServer.Address from the ContentDirectory control URL host. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
189 lines
4.8 KiB
Go
189 lines
4.8 KiB
Go
package discovery
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net/url"
|
|
"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,
|
|
}
|
|
|
|
// Populate Address from the CDS control URL host so callers know which
|
|
// host:port to reach the device on. The control URL is absolute after
|
|
// FetchDescription resolves it; parse errors leave Address empty.
|
|
if u, err := url.Parse(svc.ControlURL); err == nil {
|
|
srv.Address = u.Host
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|