mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
11b59d3911
commit
ad0f1fbd8f
@@ -0,0 +1,180 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMediaServerFromDescription_WithCDS verifies that a description that
|
||||
// includes a ContentDirectory service produces a valid MediaServer (ok=true)
|
||||
// with all fields populated.
|
||||
func TestMediaServerFromDescription_WithCDS(t *testing.T) {
|
||||
// Use the canned XML defined in ssdp_test.go (same package).
|
||||
location := "http://192.0.2.1:49000/rootDesc.xml"
|
||||
|
||||
desc, err := parseDescription([]byte(cannedDescriptionXML), location)
|
||||
if err != nil {
|
||||
t.Fatalf("parseDescription: %v", err)
|
||||
}
|
||||
|
||||
srv, ok := mediaServerFromDescription(desc)
|
||||
if !ok {
|
||||
t.Fatal("mediaServerFromDescription: ok=false, want true")
|
||||
}
|
||||
|
||||
if srv.UDN == "" {
|
||||
t.Error("UDN is empty")
|
||||
}
|
||||
|
||||
if srv.FriendlyName == "" {
|
||||
t.Error("FriendlyName is empty")
|
||||
}
|
||||
|
||||
if srv.CDSControlURL == "" {
|
||||
t.Error("CDSControlURL is empty")
|
||||
}
|
||||
|
||||
// Control URL must be absolute.
|
||||
if !isAbsoluteURL(srv.CDSControlURL) {
|
||||
t.Errorf("CDSControlURL %q is not absolute", srv.CDSControlURL)
|
||||
}
|
||||
|
||||
// Icon must be resolved.
|
||||
if srv.IconURL == "" {
|
||||
t.Error("IconURL is empty")
|
||||
}
|
||||
|
||||
if !isAbsoluteURL(srv.IconURL) {
|
||||
t.Errorf("IconURL %q is not absolute", srv.IconURL)
|
||||
}
|
||||
|
||||
t.Logf("MediaServer: UDN=%q FriendlyName=%q CDSControlURL=%q IconURL=%q",
|
||||
srv.UDN, srv.FriendlyName, srv.CDSControlURL, srv.IconURL)
|
||||
}
|
||||
|
||||
// TestMediaServerFromDescription_WithoutCDS verifies that a description
|
||||
// without a ContentDirectory service returns ok=false.
|
||||
func TestMediaServerFromDescription_WithoutCDS(t *testing.T) {
|
||||
const xmlNoCDS = `<?xml version="1.0"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
|
||||
<friendlyName>SoundTouch 20</friendlyName>
|
||||
<manufacturer>Bose</manufacturer>
|
||||
<modelName>SoundTouch 20</modelName>
|
||||
<UDN>uuid:bose-st20-0001</UDN>
|
||||
<serviceList>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
|
||||
<controlURL>/ctl/AVTransport</controlURL>
|
||||
</service>
|
||||
</serviceList>
|
||||
</device>
|
||||
</root>`
|
||||
|
||||
desc, err := parseDescription([]byte(xmlNoCDS), "http://192.0.2.10:8200/desc.xml")
|
||||
if err != nil {
|
||||
t.Fatalf("parseDescription: %v", err)
|
||||
}
|
||||
|
||||
_, ok := mediaServerFromDescription(desc)
|
||||
if ok {
|
||||
t.Error("mediaServerFromDescription: ok=true, want false (no ContentDirectory)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMediaServerFromDescription_Nil ensures a nil Description returns ok=false
|
||||
// without panicking.
|
||||
func TestMediaServerFromDescription_Nil(t *testing.T) {
|
||||
_, ok := mediaServerFromDescription(nil)
|
||||
if ok {
|
||||
t.Error("mediaServerFromDescription(nil): ok=true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMediaServerFromDescription_FlatServer verifies a flat description (no
|
||||
// sub-devices, CDS in root) maps correctly.
|
||||
func TestMediaServerFromDescription_FlatServer(t *testing.T) {
|
||||
const xmlFlat = `<?xml version="1.0"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<URLBase>http://198.51.100.20:8200</URLBase>
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
|
||||
<friendlyName>MiniDLNA</friendlyName>
|
||||
<manufacturer>Justin Maggard</manufacturer>
|
||||
<modelName>MiniDLNA</modelName>
|
||||
<UDN>uuid:minidlna-0001</UDN>
|
||||
<iconList>
|
||||
<icon>
|
||||
<mimetype>image/png</mimetype>
|
||||
<width>48</width>
|
||||
<height>48</height>
|
||||
<url>/icons/sm.png</url>
|
||||
</icon>
|
||||
</iconList>
|
||||
<serviceList>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:ContentDirectory:1</serviceType>
|
||||
<controlURL>/ctl/ContentDir</controlURL>
|
||||
</service>
|
||||
</serviceList>
|
||||
</device>
|
||||
</root>`
|
||||
|
||||
desc, err := parseDescription([]byte(xmlFlat), "http://198.51.100.20:8200/rootDesc.xml")
|
||||
if err != nil {
|
||||
t.Fatalf("parseDescription: %v", err)
|
||||
}
|
||||
|
||||
srv, ok := mediaServerFromDescription(desc)
|
||||
if !ok {
|
||||
t.Fatal("mediaServerFromDescription: ok=false, want true")
|
||||
}
|
||||
|
||||
if srv.FriendlyName != "MiniDLNA" {
|
||||
t.Errorf("FriendlyName = %q, want %q", srv.FriendlyName, "MiniDLNA")
|
||||
}
|
||||
|
||||
if srv.UDN != "uuid:minidlna-0001" {
|
||||
t.Errorf("UDN = %q, want %q", srv.UDN, "uuid:minidlna-0001")
|
||||
}
|
||||
|
||||
wantCDS := "http://198.51.100.20:8200/ctl/ContentDir"
|
||||
if srv.CDSControlURL != wantCDS {
|
||||
t.Errorf("CDSControlURL = %q, want %q", srv.CDSControlURL, wantCDS)
|
||||
}
|
||||
|
||||
wantIcon := "http://198.51.100.20:8200/icons/sm.png"
|
||||
if srv.IconURL != wantIcon {
|
||||
t.Errorf("IconURL = %q, want %q", srv.IconURL, wantIcon)
|
||||
}
|
||||
}
|
||||
|
||||
// isAbsoluteURL returns true when s starts with "http://" or "https://".
|
||||
func isAbsoluteURL(s string) bool {
|
||||
return len(s) > 7 && (s[:7] == "http://" || (len(s) > 8 && s[:8] == "https://"))
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
// 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()
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// cannedDescriptionXML is a realistic UPnP device description that includes
|
||||
// a root device with one icon, a ContentDirectory service, and one sub-device
|
||||
// (mimicking the FRITZ!Box nesting pattern). Used to exercise parseDescription
|
||||
// and FindService without any network I/O.
|
||||
const cannedDescriptionXML = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<specVersion><major>1</major><minor>0</minor></specVersion>
|
||||
<URLBase>http://192.0.2.1:49000</URLBase>
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:Basic:1</deviceType>
|
||||
<friendlyName>FRITZ!Box 7590</friendlyName>
|
||||
<manufacturer>AVM</manufacturer>
|
||||
<modelName>FRITZ!Box 7590</modelName>
|
||||
<serialNumber>SN-001</serialNumber>
|
||||
<UDN>uuid:root-device-0001</UDN>
|
||||
<iconList>
|
||||
<icon>
|
||||
<mimetype>image/png</mimetype>
|
||||
<width>48</width>
|
||||
<height>48</height>
|
||||
<url>/icons/root.png</url>
|
||||
</icon>
|
||||
</iconList>
|
||||
<serviceList>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:Layer3Forwarding:1</serviceType>
|
||||
<controlURL>/ctl/L3Fwd</controlURL>
|
||||
<eventSubURL>/evt/L3Fwd</eventSubURL>
|
||||
<SCPDURL>/L3Fwd.xml</SCPDURL>
|
||||
</service>
|
||||
</serviceList>
|
||||
<deviceList>
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
|
||||
<friendlyName>FRITZ!Box NAS</friendlyName>
|
||||
<manufacturer>AVM</manufacturer>
|
||||
<modelName>FRITZ!NAS</modelName>
|
||||
<serialNumber>SN-002</serialNumber>
|
||||
<UDN>uuid:media-server-0001</UDN>
|
||||
<iconList>
|
||||
<icon>
|
||||
<mimetype>image/png</mimetype>
|
||||
<width>32</width>
|
||||
<height>32</height>
|
||||
<url>/icons/nas.png</url>
|
||||
</icon>
|
||||
</iconList>
|
||||
<serviceList>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:ContentDirectory:1</serviceType>
|
||||
<controlURL>/ctl/ContentDir</controlURL>
|
||||
<eventSubURL>/evt/ContentDir</eventSubURL>
|
||||
<SCPDURL>/ContentDir.xml</SCPDURL>
|
||||
</service>
|
||||
</serviceList>
|
||||
</device>
|
||||
</deviceList>
|
||||
</device>
|
||||
</root>`
|
||||
|
||||
// TestParseDescription_Fields checks that parseDescription populates the
|
||||
// root-device fields correctly from the canned XML.
|
||||
func TestParseDescription_Fields(t *testing.T) {
|
||||
location := "http://192.0.2.1:49000/rootDesc.xml"
|
||||
|
||||
desc, err := parseDescription([]byte(cannedDescriptionXML), location)
|
||||
if err != nil {
|
||||
t.Fatalf("parseDescription: %v", err)
|
||||
}
|
||||
|
||||
if desc.URLBase != "http://192.0.2.1:49000" {
|
||||
t.Errorf("URLBase = %q, want %q", desc.URLBase, "http://192.0.2.1:49000")
|
||||
}
|
||||
|
||||
root := desc.Root
|
||||
|
||||
if root.FriendlyName != "FRITZ!Box 7590" {
|
||||
t.Errorf("FriendlyName = %q, want %q", root.FriendlyName, "FRITZ!Box 7590")
|
||||
}
|
||||
|
||||
if root.UDN != "uuid:root-device-0001" {
|
||||
t.Errorf("UDN = %q, want %q", root.UDN, "uuid:root-device-0001")
|
||||
}
|
||||
|
||||
if root.Manufacturer != "AVM" {
|
||||
t.Errorf("Manufacturer = %q, want %q", root.Manufacturer, "AVM")
|
||||
}
|
||||
|
||||
if root.ModelName != "FRITZ!Box 7590" {
|
||||
t.Errorf("ModelName = %q, want %q", root.ModelName, "FRITZ!Box 7590")
|
||||
}
|
||||
|
||||
if len(root.Devices) != 1 {
|
||||
t.Fatalf("root sub-devices = %d, want 1", len(root.Devices))
|
||||
}
|
||||
|
||||
sub := root.Devices[0]
|
||||
|
||||
if sub.FriendlyName != "FRITZ!Box NAS" {
|
||||
t.Errorf("sub FriendlyName = %q, want %q", sub.FriendlyName, "FRITZ!Box NAS")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseDescription_FindService confirms that FindService recurses into
|
||||
// sub-devices and resolves the controlURL to an absolute form using URLBase.
|
||||
func TestParseDescription_FindService(t *testing.T) {
|
||||
location := "http://192.0.2.1:49000/rootDesc.xml"
|
||||
|
||||
desc, err := parseDescription([]byte(cannedDescriptionXML), location)
|
||||
if err != nil {
|
||||
t.Fatalf("parseDescription: %v", err)
|
||||
}
|
||||
|
||||
// ContentDirectory is in the sub-device, not the root.
|
||||
svc, ok := desc.FindService("urn:schemas-upnp-org:service:ContentDirectory:1")
|
||||
if !ok {
|
||||
t.Fatal("FindService(ContentDirectory:1): not found")
|
||||
}
|
||||
|
||||
// URLBase is http://192.0.2.1:49000, controlURL is /ctl/ContentDir.
|
||||
wantControlURL := "http://192.0.2.1:49000/ctl/ContentDir"
|
||||
if svc.ControlURL != wantControlURL {
|
||||
t.Errorf("ControlURL = %q, want %q", svc.ControlURL, wantControlURL)
|
||||
}
|
||||
|
||||
// Root-only service should also be found.
|
||||
l3, ok := desc.FindService("urn:schemas-upnp-org:service:Layer3Forwarding:1")
|
||||
if !ok {
|
||||
t.Fatal("FindService(Layer3Forwarding:1): not found")
|
||||
}
|
||||
|
||||
if l3.ControlURL != "http://192.0.2.1:49000/ctl/L3Fwd" {
|
||||
t.Errorf("Layer3Forwarding ControlURL = %q", l3.ControlURL)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseDescription_FindService_Missing ensures false is returned when the
|
||||
// service does not exist in the tree.
|
||||
func TestParseDescription_FindService_Missing(t *testing.T) {
|
||||
location := "http://192.0.2.1:49000/rootDesc.xml"
|
||||
|
||||
desc, err := parseDescription([]byte(cannedDescriptionXML), location)
|
||||
if err != nil {
|
||||
t.Fatalf("parseDescription: %v", err)
|
||||
}
|
||||
|
||||
_, ok := desc.FindService("urn:schemas-upnp-org:service:DoesNotExist:1")
|
||||
if ok {
|
||||
t.Error("FindService(DoesNotExist:1) returned ok=true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseDescription_FirstIcon checks that icon URLs are resolved to
|
||||
// absolute form and that FirstIcon returns the root-device icon.
|
||||
func TestParseDescription_FirstIcon(t *testing.T) {
|
||||
location := "http://192.0.2.1:49000/rootDesc.xml"
|
||||
|
||||
desc, err := parseDescription([]byte(cannedDescriptionXML), location)
|
||||
if err != nil {
|
||||
t.Fatalf("parseDescription: %v", err)
|
||||
}
|
||||
|
||||
ic, ok := desc.FirstIcon()
|
||||
if !ok {
|
||||
t.Fatal("FirstIcon: not found")
|
||||
}
|
||||
|
||||
wantURL := "http://192.0.2.1:49000/icons/root.png"
|
||||
if ic.URL != wantURL {
|
||||
t.Errorf("icon URL = %q, want %q", ic.URL, wantURL)
|
||||
}
|
||||
|
||||
if ic.Width != 48 {
|
||||
t.Errorf("icon Width = %d, want 48", ic.Width)
|
||||
}
|
||||
|
||||
if ic.MimeType != "image/png" {
|
||||
t.Errorf("icon MimeType = %q, want %q", ic.MimeType, "image/png")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseDescription_RelativeURLResolution tests URL resolution without
|
||||
// URLBase (falls back to the location URL).
|
||||
func TestParseDescription_RelativeURLResolution(t *testing.T) {
|
||||
const xmlNoURLBase = `<?xml version="1.0"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
|
||||
<friendlyName>Mini NAS</friendlyName>
|
||||
<UDN>uuid:mini-001</UDN>
|
||||
<serviceList>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:ContentDirectory:1</serviceType>
|
||||
<controlURL>/ctl/CDS</controlURL>
|
||||
</service>
|
||||
</serviceList>
|
||||
</device>
|
||||
</root>`
|
||||
|
||||
location := "http://198.51.100.5:8200/rootDesc.xml"
|
||||
|
||||
desc, err := parseDescription([]byte(xmlNoURLBase), location)
|
||||
if err != nil {
|
||||
t.Fatalf("parseDescription: %v", err)
|
||||
}
|
||||
|
||||
svc, ok := desc.FindService("urn:schemas-upnp-org:service:ContentDirectory:1")
|
||||
if !ok {
|
||||
t.Fatal("FindService: not found")
|
||||
}
|
||||
|
||||
// Without URLBase, base URL comes from location.
|
||||
want := "http://198.51.100.5:8200/ctl/CDS"
|
||||
if svc.ControlURL != want {
|
||||
t.Errorf("ControlURL = %q, want %q", svc.ControlURL, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAbsURL_Variants exercises the absURL helper with several input
|
||||
// combinations.
|
||||
func TestAbsURL_Variants(t *testing.T) {
|
||||
cases := []struct {
|
||||
base string
|
||||
ref string
|
||||
want string
|
||||
}{
|
||||
// Relative path resolved against explicit-port base.
|
||||
{"http://192.0.2.1:49000/desc.xml", "/ctl/CDS", "http://192.0.2.1:49000/ctl/CDS"},
|
||||
// Already absolute: returned unchanged.
|
||||
{"http://192.0.2.1:49000/", "http://198.51.100.5:8200/ctl/CDS", "http://198.51.100.5:8200/ctl/CDS"},
|
||||
// Empty ref: returned as-is.
|
||||
{"http://192.0.2.1:49000/", "", ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
base, err := url.Parse(tc.base)
|
||||
if err != nil {
|
||||
t.Fatalf("url.Parse(%q): %v", tc.base, err)
|
||||
}
|
||||
|
||||
got := absURL(base, tc.ref)
|
||||
|
||||
if got != tc.want {
|
||||
t.Errorf("absURL(%q, %q) = %q, want %q", tc.base, tc.ref, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
// Package dlna is a minimal DLNA / UPnP ContentDirectory browse client.
|
||||
// It talks to a MediaServer's ContentDirectory:1 service via SOAP Browse
|
||||
// actions, and parses the DIDL-Lite responses into structured Go types.
|
||||
//
|
||||
// Device discovery lives in pkg/discovery; this package is only the browse half.
|
||||
package dlna
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
)
|
||||
|
||||
// BrowseResult holds one page of a ContentDirectory Browse response.
|
||||
type BrowseResult struct {
|
||||
Containers []Container
|
||||
Items []Item
|
||||
TotalMatches int
|
||||
Returned int
|
||||
}
|
||||
|
||||
// Container is a folder / album / playlist node in the DLNA content tree.
|
||||
type Container struct {
|
||||
ID string
|
||||
ParentID string
|
||||
Title string
|
||||
ChildCount int
|
||||
}
|
||||
|
||||
// Item is a single playable object (track, photo, video). Use IsAudioItem to
|
||||
// check whether a SoundTouch renderer can play it.
|
||||
type Item struct {
|
||||
ID string
|
||||
ParentID string
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
Class string
|
||||
MimeType string
|
||||
StreamURL string
|
||||
AlbumArtURL string
|
||||
DurationSec int
|
||||
}
|
||||
|
||||
// IsAudioItem reports whether the item is an audio track. Photos, videos, and
|
||||
// unrecognised items return false.
|
||||
func (it Item) IsAudioItem() bool {
|
||||
if strings.HasPrefix(strings.ToLower(it.MimeType), "audio/") {
|
||||
return true
|
||||
}
|
||||
|
||||
c := strings.ToLower(it.Class)
|
||||
|
||||
return strings.Contains(c, "audioitem") || strings.Contains(c, "musictrack")
|
||||
}
|
||||
|
||||
// Browse calls ContentDirectory:Browse on srv and returns one page of results.
|
||||
// objectID "0" is the server root. start is the page offset, count the page
|
||||
// size (0 defaults to 50 on the caller side so the request is always bounded).
|
||||
func Browse(ctx context.Context, srv discovery.MediaServer, objectID string, start, count int) (BrowseResult, error) {
|
||||
if srv.CDSControlURL == "" {
|
||||
return BrowseResult{}, fmt.Errorf("dlna: server %q has no ContentDirectory control URL", srv.FriendlyName)
|
||||
}
|
||||
|
||||
if objectID == "" {
|
||||
objectID = "0"
|
||||
}
|
||||
|
||||
if count <= 0 {
|
||||
count = 50
|
||||
}
|
||||
|
||||
body := fmt.Sprintf(
|
||||
`<?xml version="1.0" encoding="utf-8"?>`+
|
||||
`<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" `+
|
||||
`s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">`+
|
||||
`<s:Body>`+
|
||||
`<u:Browse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1">`+
|
||||
`<ObjectID>%s</ObjectID>`+
|
||||
`<BrowseFlag>BrowseDirectChildren</BrowseFlag>`+
|
||||
`<Filter>*</Filter>`+
|
||||
`<StartingIndex>%d</StartingIndex>`+
|
||||
`<RequestedCount>%d</RequestedCount>`+
|
||||
`<SortCriteria></SortCriteria>`+
|
||||
`</u:Browse>`+
|
||||
`</s:Body>`+
|
||||
`</s:Envelope>`,
|
||||
xmlEscape(objectID), start, count,
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, srv.CDSControlURL, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return BrowseResult{}, fmt.Errorf("dlna: build Browse request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", `text/xml; charset="utf-8"`)
|
||||
req.Header.Set("SOAPACTION", `"urn:schemas-upnp-org:service:ContentDirectory:1#Browse"`)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return BrowseResult{}, fmt.Errorf("dlna: Browse request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return BrowseResult{}, fmt.Errorf("dlna: read Browse response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return BrowseResult{}, fmt.Errorf("dlna: Browse status %d: %s", resp.StatusCode, truncate(string(raw), 240))
|
||||
}
|
||||
|
||||
return parseBrowseResponse(raw)
|
||||
}
|
||||
|
||||
// soapBrowseEnvelope is the relevant subset of the Browse SOAP response.
|
||||
type soapBrowseEnvelope struct {
|
||||
XMLName xml.Name `xml:"Envelope"`
|
||||
Body struct {
|
||||
BrowseResponse struct {
|
||||
Result string `xml:"Result"`
|
||||
NumberReturned int `xml:"NumberReturned"`
|
||||
TotalMatches int `xml:"TotalMatches"`
|
||||
} `xml:"BrowseResponse"`
|
||||
} `xml:"Body"`
|
||||
}
|
||||
|
||||
// didlLite mirrors the embedded DIDL-Lite XML returned in the <Result> element.
|
||||
type didlLite struct {
|
||||
XMLName xml.Name `xml:"DIDL-Lite"`
|
||||
Containers []didlContainer `xml:"container"`
|
||||
Items []didlItem `xml:"item"`
|
||||
}
|
||||
|
||||
type didlContainer struct {
|
||||
ID string `xml:"id,attr"`
|
||||
ParentID string `xml:"parentID,attr"`
|
||||
ChildCount int `xml:"childCount,attr"`
|
||||
Title string `xml:"title"`
|
||||
}
|
||||
|
||||
type didlItem struct {
|
||||
ID string `xml:"id,attr"`
|
||||
ParentID string `xml:"parentID,attr"`
|
||||
Title string `xml:"title"`
|
||||
Class string `xml:"class"`
|
||||
Artist string `xml:"artist"`
|
||||
Album string `xml:"album"`
|
||||
AlbumArt string `xml:"albumArtURI"`
|
||||
Res []didlR `xml:"res"`
|
||||
}
|
||||
|
||||
type didlR struct {
|
||||
ProtocolInfo string `xml:"protocolInfo,attr"`
|
||||
Duration string `xml:"duration,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
// parseBrowseResponse is a pure function: it parses raw SOAP Browse response
|
||||
// bytes (including the nested DIDL-Lite inside <Result>) into a BrowseResult.
|
||||
// Testable without HTTP.
|
||||
func parseBrowseResponse(raw []byte) (BrowseResult, error) {
|
||||
var env soapBrowseEnvelope
|
||||
|
||||
if err := xml.Unmarshal(raw, &env); err != nil {
|
||||
return BrowseResult{}, fmt.Errorf("dlna: parse SOAP envelope: %w", err)
|
||||
}
|
||||
|
||||
resultXML := env.Body.BrowseResponse.Result
|
||||
|
||||
if resultXML == "" {
|
||||
return BrowseResult{
|
||||
TotalMatches: env.Body.BrowseResponse.TotalMatches,
|
||||
Returned: env.Body.BrowseResponse.NumberReturned,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var didl didlLite
|
||||
|
||||
if err := xml.Unmarshal([]byte(resultXML), &didl); err != nil {
|
||||
return BrowseResult{}, fmt.Errorf("dlna: parse DIDL-Lite: %w", err)
|
||||
}
|
||||
|
||||
out := BrowseResult{
|
||||
TotalMatches: env.Body.BrowseResponse.TotalMatches,
|
||||
Returned: env.Body.BrowseResponse.NumberReturned,
|
||||
}
|
||||
|
||||
for _, c := range didl.Containers {
|
||||
out.Containers = append(out.Containers, Container{
|
||||
ID: c.ID,
|
||||
ParentID: c.ParentID,
|
||||
Title: c.Title,
|
||||
ChildCount: c.ChildCount,
|
||||
})
|
||||
}
|
||||
|
||||
for i := range didl.Items {
|
||||
it := &didl.Items[i]
|
||||
stream := ""
|
||||
mime := ""
|
||||
duration := 0
|
||||
|
||||
if len(it.Res) > 0 {
|
||||
stream = strings.TrimSpace(it.Res[0].Value)
|
||||
mime = MimeFromProtocolInfo(it.Res[0].ProtocolInfo)
|
||||
duration = ParseHMS(it.Res[0].Duration)
|
||||
}
|
||||
|
||||
out.Items = append(out.Items, Item{
|
||||
ID: it.ID,
|
||||
ParentID: it.ParentID,
|
||||
Title: it.Title,
|
||||
Class: it.Class,
|
||||
Artist: it.Artist,
|
||||
Album: it.Album,
|
||||
AlbumArtURL: it.AlbumArt,
|
||||
StreamURL: stream,
|
||||
MimeType: mime,
|
||||
DurationSec: duration,
|
||||
})
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MimeFromProtocolInfo extracts the MIME type from a DLNA protocolInfo string.
|
||||
// Format is "protocol:network:contentType:additionalInfo", e.g.
|
||||
// "http-get:*:audio/mpeg:*". Returns the third colon-separated field.
|
||||
func MimeFromProtocolInfo(pi string) string {
|
||||
parts := strings.Split(pi, ":")
|
||||
if len(parts) < 3 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return parts[2]
|
||||
}
|
||||
|
||||
// ParseHMS converts a DIDL-Lite duration string in "H:MM:SS[.mmm]" format
|
||||
// to a total number of seconds.
|
||||
func ParseHMS(d string) int {
|
||||
if d == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Strip optional fractional seconds ("0:03:42.000" -> "0:03:42").
|
||||
if idx := strings.Index(d, "."); idx >= 0 {
|
||||
d = d[:idx]
|
||||
}
|
||||
|
||||
parts := strings.Split(d, ":")
|
||||
if len(parts) != 3 {
|
||||
return 0
|
||||
}
|
||||
|
||||
h, m, s := 0, 0, 0
|
||||
_, _ = fmt.Sscanf(parts[0], "%d", &h)
|
||||
_, _ = fmt.Sscanf(parts[1], "%d", &m)
|
||||
_, _ = fmt.Sscanf(parts[2], "%d", &s)
|
||||
|
||||
return h*3600 + m*60 + s
|
||||
}
|
||||
|
||||
// xmlEscape returns s as XML-safe text (escapes &, <, >, ", ').
|
||||
func xmlEscape(s string) string {
|
||||
var b strings.Builder
|
||||
|
||||
xml.EscapeText(&b, []byte(s)) //nolint:errcheck // strings.Builder never errors
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// truncate returns the first n bytes of s followed by "..." when len(s) > n.
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
|
||||
return s[:n] + "..."
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package dlna_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/dlna"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/dlna/dlnatest"
|
||||
)
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Integration tests using the in-process DLNA test fixture
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// TestBrowse_Root checks that Browse("0") returns the Music container from the
|
||||
// default test tree.
|
||||
func TestBrowse_Root(t *testing.T) {
|
||||
ts, _ := dlnatest.NewHTTPTest()
|
||||
defer ts.Close()
|
||||
|
||||
srv := discovery.MediaServer{
|
||||
FriendlyName: "Test Server",
|
||||
CDSControlURL: ts.URL + "/ctl/ContentDir",
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := dlna.Browse(ctx, srv, "0", 0, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("Browse root: %v", err)
|
||||
}
|
||||
|
||||
if len(result.Containers) == 0 {
|
||||
t.Fatal("Browse root: got 0 containers, want at least 1")
|
||||
}
|
||||
|
||||
var musicContainer *dlna.Container
|
||||
|
||||
for i := range result.Containers {
|
||||
if result.Containers[i].Title == "Music" {
|
||||
musicContainer = &result.Containers[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if musicContainer == nil {
|
||||
t.Fatalf("Browse root: Music container not found; got %v", result.Containers)
|
||||
}
|
||||
|
||||
if musicContainer.ID == "" {
|
||||
t.Error("Music container has empty ID")
|
||||
}
|
||||
|
||||
t.Logf("Music container: id=%q parentID=%q childCount=%d",
|
||||
musicContainer.ID, musicContainer.ParentID, musicContainer.ChildCount)
|
||||
}
|
||||
|
||||
// TestBrowse_MusicFolder checks that browsing into the Music container returns
|
||||
// exactly 2 audio items with non-empty StreamURLs that are fetchable.
|
||||
func TestBrowse_MusicFolder(t *testing.T) {
|
||||
ts, _ := dlnatest.NewHTTPTest()
|
||||
defer ts.Close()
|
||||
|
||||
srv := discovery.MediaServer{
|
||||
FriendlyName: "Test Server",
|
||||
CDSControlURL: ts.URL + "/ctl/ContentDir",
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// First, browse root to find the Music folder ID.
|
||||
root, err := dlna.Browse(ctx, srv, "0", 0, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("Browse root: %v", err)
|
||||
}
|
||||
|
||||
var musicID string
|
||||
|
||||
for _, c := range root.Containers {
|
||||
if c.Title == "Music" {
|
||||
musicID = c.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if musicID == "" {
|
||||
t.Fatal("Music container not found in root browse")
|
||||
}
|
||||
|
||||
// Now browse the Music folder.
|
||||
result, err := dlna.Browse(ctx, srv, musicID, 0, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("Browse music folder: %v", err)
|
||||
}
|
||||
|
||||
if len(result.Items) != 2 {
|
||||
t.Fatalf("expected 2 audio items, got %d", len(result.Items))
|
||||
}
|
||||
|
||||
for _, item := range result.Items {
|
||||
t.Run(item.Title, func(t *testing.T) {
|
||||
if item.Title == "" {
|
||||
t.Error("item has empty Title")
|
||||
}
|
||||
|
||||
if !item.IsAudioItem() {
|
||||
t.Errorf("IsAudioItem() = false for item %q (MimeType=%q Class=%q)",
|
||||
item.Title, item.MimeType, item.Class)
|
||||
}
|
||||
|
||||
if item.Artist == "" {
|
||||
t.Errorf("item %q has empty Artist", item.Title)
|
||||
} else if item.Artist != "Test Artist" {
|
||||
t.Errorf("item %q: Artist = %q, want %q", item.Title, item.Artist, "Test Artist")
|
||||
}
|
||||
|
||||
if item.Album == "" {
|
||||
t.Errorf("item %q has empty Album", item.Title)
|
||||
} else if item.Album != "Test Album" {
|
||||
t.Errorf("item %q: Album = %q, want %q", item.Title, item.Album, "Test Album")
|
||||
}
|
||||
|
||||
if item.StreamURL == "" {
|
||||
t.Fatalf("item %q has empty StreamURL", item.Title)
|
||||
}
|
||||
|
||||
// Fetch the stream URL and verify it returns audio bytes.
|
||||
resp, err := http.Get(item.StreamURL) //nolint:noctx
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", item.StreamURL, err)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET %s: status %d", item.StreamURL, resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read media body: %v", err)
|
||||
}
|
||||
|
||||
if len(data) < 44 {
|
||||
t.Errorf("audio payload too small (%d bytes), expected at least a WAV header", len(data))
|
||||
}
|
||||
|
||||
// Verify RIFF/WAVE header (silentWAV always produces PCM WAV).
|
||||
if string(data[0:4]) != "RIFF" {
|
||||
t.Errorf("expected RIFF header, got %q", data[0:4])
|
||||
}
|
||||
|
||||
if string(data[8:12]) != "WAVE" {
|
||||
t.Errorf("expected WAVE marker, got %q", data[8:12])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowse_NoCDSControlURL verifies that Browse returns an error when the
|
||||
// server has no CDSControlURL set.
|
||||
func TestBrowse_NoCDSControlURL(t *testing.T) {
|
||||
srv := discovery.MediaServer{FriendlyName: "Empty"}
|
||||
_, err := dlna.Browse(context.Background(), srv, "0", 0, 50)
|
||||
|
||||
if err == nil {
|
||||
t.Error("Browse with empty CDSControlURL: expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Pure function unit tests
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// TestMimeFromProtocolInfo checks the DLNA protocolInfo MIME extraction.
|
||||
func TestMimeFromProtocolInfo(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"http-get:*:audio/x-wav:*", "audio/x-wav"},
|
||||
{"http-get:*:audio/mpeg:*", "audio/mpeg"},
|
||||
{"http-get:*:audio/ogg:DLNA.ORG_PN=OGG", "audio/ogg"},
|
||||
{"http-get:*:image/jpeg:*", "image/jpeg"},
|
||||
// Fewer than 3 colons: return empty string.
|
||||
{"http-get", ""},
|
||||
{"http-get:*", ""},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := dlna.MimeFromProtocolInfo(tc.input)
|
||||
if got != tc.want {
|
||||
t.Errorf("MimeFromProtocolInfo(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseHMS checks duration string parsing to seconds.
|
||||
func TestParseHMS(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
want int
|
||||
}{
|
||||
{"0:00:01.000", 1},
|
||||
{"0:00:01", 1},
|
||||
{"0:03:42", 222},
|
||||
{"0:03:42.000", 222},
|
||||
{"1:00:00", 3600},
|
||||
{"1:30:00", 5400},
|
||||
{"0:00:00", 0},
|
||||
{"", 0},
|
||||
// Malformed: return 0.
|
||||
{"99:99", 0},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := dlna.ParseHMS(tc.input)
|
||||
if got != tc.want {
|
||||
t.Errorf("ParseHMS(%q) = %d, want %d", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsAudioItem checks the audio-item classifier.
|
||||
func TestIsAudioItem(t *testing.T) {
|
||||
cases := []struct {
|
||||
item dlna.Item
|
||||
want bool
|
||||
}{
|
||||
// MimeType prefix "audio/" is sufficient.
|
||||
{dlna.Item{MimeType: "audio/x-wav"}, true},
|
||||
{dlna.Item{MimeType: "audio/mpeg"}, true},
|
||||
// Class "audioitem" (any case).
|
||||
{dlna.Item{Class: "object.item.audioItem.musicTrack"}, true},
|
||||
{dlna.Item{Class: "object.item.musicTrack"}, true},
|
||||
// Video and image MIME types: not audio.
|
||||
{dlna.Item{MimeType: "video/mp4"}, false},
|
||||
{dlna.Item{MimeType: "image/jpeg"}, false},
|
||||
// Empty item.
|
||||
{dlna.Item{}, false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := tc.item.IsAudioItem()
|
||||
if got != tc.want {
|
||||
t.Errorf("Item{MimeType:%q Class:%q}.IsAudioItem() = %v, want %v",
|
||||
tc.item.MimeType, tc.item.Class, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,8 @@ func DefaultTree() *Tree {
|
||||
ParentID: "1$4",
|
||||
Title: "track01",
|
||||
Class: "object.item.audioItem.musicTrack",
|
||||
Artist: "Test Artist",
|
||||
Album: "Test Album",
|
||||
MimeType: "audio/x-wav",
|
||||
DurSec: 1.0,
|
||||
Payload: track01,
|
||||
@@ -88,6 +90,8 @@ func DefaultTree() *Tree {
|
||||
ParentID: "1$4",
|
||||
Title: "track02",
|
||||
Class: "object.item.audioItem.musicTrack",
|
||||
Artist: "Test Artist",
|
||||
Album: "Test Album",
|
||||
MimeType: "audio/x-wav",
|
||||
DurSec: 1.0,
|
||||
Payload: track02,
|
||||
@@ -412,7 +416,11 @@ func (s *Server) browseContainer(c *Container, start, count int, base string) (s
|
||||
b.WriteString(`<dc:title>` + xmlEsc(it.Title) + `</dc:title>`)
|
||||
|
||||
if it.Artist != "" {
|
||||
b.WriteString(`<dc:creator>` + xmlEsc(it.Artist) + `</dc:creator>`)
|
||||
b.WriteString(`<upnp:artist>` + xmlEsc(it.Artist) + `</upnp:artist>`)
|
||||
}
|
||||
|
||||
if it.Album != "" {
|
||||
b.WriteString(`<upnp:album>` + xmlEsc(it.Album) + `</upnp:album>`)
|
||||
}
|
||||
|
||||
b.WriteString(`<upnp:class>` + xmlEsc(it.Class) + `</upnp:class>`)
|
||||
|
||||
Reference in New Issue
Block a user