diff --git a/pkg/discovery/mediaserver.go b/pkg/discovery/mediaserver.go
new file mode 100644
index 0000000..65d874a
--- /dev/null
+++ b/pkg/discovery/mediaserver.go
@@ -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)
+ }
+}
diff --git a/pkg/discovery/mediaserver_test.go b/pkg/discovery/mediaserver_test.go
new file mode 100644
index 0000000..179e65d
--- /dev/null
+++ b/pkg/discovery/mediaserver_test.go
@@ -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 = `
+
+
+ urn:schemas-upnp-org:device:MediaRenderer:1
+ SoundTouch 20
+ Bose
+ SoundTouch 20
+ uuid:bose-st20-0001
+
+
+ urn:schemas-upnp-org:service:AVTransport:1
+ /ctl/AVTransport
+
+
+
+`
+
+ 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 = `
+
+ http://198.51.100.20:8200
+
+ urn:schemas-upnp-org:device:MediaServer:1
+ MiniDLNA
+ Justin Maggard
+ MiniDLNA
+ uuid:minidlna-0001
+
+
+ image/png
+ 48
+ 48
+ /icons/sm.png
+
+
+
+
+ urn:schemas-upnp-org:service:ContentDirectory:1
+ /ctl/ContentDir
+
+
+
+`
+
+ 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://"))
+}
diff --git a/pkg/discovery/ssdp.go b/pkg/discovery/ssdp.go
new file mode 100644
index 0000000..2ddfd03
--- /dev/null
+++ b/pkg/discovery/ssdp.go
@@ -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()
+}
diff --git a/pkg/discovery/ssdp_test.go b/pkg/discovery/ssdp_test.go
new file mode 100644
index 0000000..27e9132
--- /dev/null
+++ b/pkg/discovery/ssdp_test.go
@@ -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 = `
+
+ 10
+ http://192.0.2.1:49000
+
+ urn:schemas-upnp-org:device:Basic:1
+ FRITZ!Box 7590
+ AVM
+ FRITZ!Box 7590
+ SN-001
+ uuid:root-device-0001
+
+
+ image/png
+ 48
+ 48
+ /icons/root.png
+
+
+
+
+ urn:schemas-upnp-org:service:Layer3Forwarding:1
+ /ctl/L3Fwd
+ /evt/L3Fwd
+ /L3Fwd.xml
+
+
+
+
+ urn:schemas-upnp-org:device:MediaServer:1
+ FRITZ!Box NAS
+ AVM
+ FRITZ!NAS
+ SN-002
+ uuid:media-server-0001
+
+
+ image/png
+ 32
+ 32
+ /icons/nas.png
+
+
+
+
+ urn:schemas-upnp-org:service:ContentDirectory:1
+ /ctl/ContentDir
+ /evt/ContentDir
+ /ContentDir.xml
+
+
+
+
+
+`
+
+// 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 = `
+
+
+ urn:schemas-upnp-org:device:MediaServer:1
+ Mini NAS
+ uuid:mini-001
+
+
+ urn:schemas-upnp-org:service:ContentDirectory:1
+ /ctl/CDS
+
+
+
+`
+
+ 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)
+ }
+ }
+}
diff --git a/pkg/dlna/browse.go b/pkg/dlna/browse.go
new file mode 100644
index 0000000..b23a618
--- /dev/null
+++ b/pkg/dlna/browse.go
@@ -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(
+ ``+
+ ``+
+ ``+
+ ``+
+ `%s`+
+ `BrowseDirectChildren`+
+ `*`+
+ `%d`+
+ `%d`+
+ ``+
+ ``+
+ ``+
+ ``,
+ 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 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 ) 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] + "..."
+}
diff --git a/pkg/dlna/browse_test.go b/pkg/dlna/browse_test.go
new file mode 100644
index 0000000..07969f3
--- /dev/null
+++ b/pkg/dlna/browse_test.go
@@ -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)
+ }
+ }
+}
diff --git a/pkg/dlna/dlnatest/dlnatest.go b/pkg/dlna/dlnatest/dlnatest.go
index 6098131..64c240a 100644
--- a/pkg/dlna/dlnatest/dlnatest.go
+++ b/pkg/dlna/dlnatest/dlnatest.go
@@ -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(`` + xmlEsc(it.Title) + ``)
if it.Artist != "" {
- b.WriteString(`` + xmlEsc(it.Artist) + ``)
+ b.WriteString(`` + xmlEsc(it.Artist) + ``)
+ }
+
+ if it.Album != "" {
+ b.WriteString(`` + xmlEsc(it.Album) + ``)
}
b.WriteString(`` + xmlEsc(it.Class) + ``)