Files
Bose-SoundTouch/pkg/discovery/mediaserver_test.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

156 lines
4.5 KiB
Go

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://"))
}