feat(discovery,dlna): generic SSDP core + media-server discovery + browse client

Foundation for browsing DLNA media servers and playing tracks on a
SoundTouch speaker (https://github.com/gesellix/Bose-SoundTouch/discussions/213).

- pkg/discovery/ssdp.go: a target-agnostic UPnP SSDP core. SearchSSDP sweeps
  multiple targets (a typed device URN plus ssdp:all, since some servers only
  answer one), fans out across all routable IPv4 interfaces, and sends each
  batch in two rounds spaced 80ms apart so slower NAS/router boxes that drop
  back-to-back bursts still answer. FetchDescription parses a UPnP device
  description into a generic device tree with FindService/FirstIcon that
  recurse through sub-devices. The XML parse is a pure function for offline
  unit testing.
- pkg/discovery/mediaserver.go: DiscoverMediaServers rides the core, keeps
  only devices exposing a ContentDirectory service, and dedupes by UDN. The
  description->MediaServer mapping is a pure, tested function.
- pkg/dlna: a ContentDirectory browse client (Browse + DIDL-Lite parse +
  IsAudioItem), consuming discovery.MediaServer. Kept separate from discovery,
  mirroring how pkg/client is separate from pkg/discovery. Track metadata maps
  upnp:artist / upnp:album; the audio filter accepts audio/* MIME or an
  audioItem/musicTrack class.

Existing SoundTouch speaker discovery (pkg/discovery/upnp.go) is untouched;
migrating it onto the shared core is a later, de-risked step. Tests cover the
description/DIDL parsers and run the browse client against an in-process
ContentDirectory server; the parse was checked against real minidlna and
FRITZ!Box output.

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