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>
156 lines
4.5 KiB
Go
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://"))
|
|
}
|