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) + ``)