test(dlna): add DLNA MediaServer test server (fixture + LAN example)

Adds a dependency-free Go DLNA/UPnP MediaServer used to develop and test
the upcoming "browse a DLNA server and play on a SoundTouch" feature
(https://github.com/gesellix/Bose-SoundTouch/discussions/213).

Two faces over one content core:
- pkg/dlna/dlnatest: an in-process server (httptest) serving rootDesc.xml
  and ContentDirectory Browse for an injectable content tree, for fast
  cross-platform unit tests with no Docker and no multicast.
- cmd/example-dlna-server: the same handlers behind a real http.Server
  plus an SSDP responder (answers M-SEARCH, periodic NOTIFY), so a real
  speaker on the LAN can discover it and fetch real (silent WAV) audio.

A Docker minidlna was unusable here: on macOS the container IP in the
DIDL <res> URL is unreachable from the LAN, and its SSDP never reaches the
speakers. A native Go server embeds the host LAN IP and is discoverable.

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 a7050fd503
commit 11b59d3911
4 changed files with 1392 additions and 0 deletions
+1
View File
@@ -21,6 +21,7 @@ dist/
/example-mdns
/example-upnp
/example-unified
/example-dlna-server
/mdns-scanner
/websocket-demo
/main
+401
View File
@@ -0,0 +1,401 @@
// Package main runs a LAN-visible DLNA / UPnP MediaServer backed by the
// dlnatest in-memory content tree.
//
// Usage:
//
// example-dlna-server [--port 8200] [--name "My Library"]
//
// The server:
// - Binds an HTTP server on 0.0.0.0:<port> (default 8200).
// - Detects the host's primary LAN IPv4 to build the SSDP LOCATION header
// and the absolute <res> URLs inside DIDL-Lite Browse responses.
// - Joins the SSDP multicast group 239.255.255.250:1900 and answers
// M-SEARCH requests whose ST matches upnp:rootdevice, ssdp:all, or
// urn:schemas-upnp-org:device:MediaServer:1.
// - Periodically sends ssdp:alive NOTIFY announcements.
// - Sends ssdp:byebye on graceful shutdown (SIGINT / SIGTERM).
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/gesellix/bose-soundtouch/pkg/dlna/dlnatest"
)
const (
ssdpMulticastAddr = "239.255.255.250:1900"
ssdpMulticastIP = "239.255.255.250"
ssdpPort = 1900
mediaServerURN = "urn:schemas-upnp-org:device:MediaServer:1"
contentDirURN = "urn:schemas-upnp-org:service:ContentDirectory:1"
notifyInterval = 30 * time.Second
ssdpMaxAge = 1800
serverVersion = "AfterTouch/1.0 UPnP/1.0 AfterTouchDLNA/1.0"
)
func main() {
port := flag.Int("port", 8200, "HTTP port to bind")
name := flag.String("name", "AfterTouch Test Library", "UPnP friendlyName advertised over SSDP")
flag.Parse()
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
lanIP, err := primaryLANIP()
if err != nil {
logger.Warn("could not detect LAN IP, falling back to loopback", "err", err)
lanIP = "127.0.0.1"
}
addr := fmt.Sprintf("0.0.0.0:%d", *port)
location := fmt.Sprintf("http://%s:%d/rootDesc.xml", lanIP, *port)
srv := dlnatest.NewServer(dlnatest.WithFriendlyName(*name))
httpSrv := &http.Server{
Addr: addr,
Handler: srv.HTTPHandler(),
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// Start HTTP server.
go func() {
logger.Info("HTTP server starting", "addr", addr, "lanIP", lanIP, "location", location)
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("HTTP server error", "err", err)
}
}()
// Give the HTTP listener a moment to bind before we advertise it.
time.Sleep(50 * time.Millisecond)
udn := srv.UDN
// Start SSDP listener + responder.
go runSSDPListener(ctx, logger, udn, location)
// Start periodic ssdp:alive announcements.
go runSSDPAlive(ctx, logger, udn, location)
logger.Info("DLNA MediaServer ready", "location", location, "name", *name)
// Wait for shutdown signal.
<-ctx.Done()
logger.Info("shutting down...")
// Send byebye before exiting.
sendByebye(logger, udn)
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := httpSrv.Shutdown(shutCtx); err != nil {
logger.Error("HTTP shutdown error", "err", err)
}
logger.Info("stopped")
}
// ----------------------------------------------------------------------------
// SSDP listener: answers M-SEARCH requests
// ----------------------------------------------------------------------------
func runSSDPListener(ctx context.Context, logger *slog.Logger, udn, location string) {
group := &net.UDPAddr{IP: net.ParseIP(ssdpMulticastIP), Port: ssdpPort}
// ListenMulticastUDP joins the multicast group on a system-chosen interface.
// We iterate over all UP multicast-capable interfaces and listen on each.
ifaces, err := multicastInterfaces()
if err != nil {
logger.Warn("SSDP: cannot list interfaces, using system default", "err", err)
ifaces = []*net.Interface{nil} // nil = system default
}
if len(ifaces) == 0 {
ifaces = []*net.Interface{nil}
}
// We only need one listening socket; use the first usable interface.
// net.ListenMulticastUDP binds to 0.0.0.0:1900 internally, so a single
// call is sufficient to receive multicast traffic on all interfaces on
// most platforms.
conn, err := net.ListenMulticastUDP("udp4", ifaces[0], group)
if err != nil {
logger.Warn("SSDP: ListenMulticastUDP failed (try running as root or check firewall)", "err", err)
return
}
defer conn.Close()
logger.Info("SSDP: listening for M-SEARCH on multicast", "group", group.String())
buf := make([]byte, 2048)
for {
select {
case <-ctx.Done():
return
default:
}
_ = conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
n, src, err := conn.ReadFromUDP(buf)
if err != nil {
// Deadline timeout is expected; just continue.
continue
}
msg := string(buf[:n])
if !strings.HasPrefix(msg, "M-SEARCH") {
continue
}
st := extractHeader(msg, "ST")
logger.Debug("SSDP: M-SEARCH received", "from", src, "ST", st)
if !stMatches(st) {
continue
}
logger.Info("SSDP: answering M-SEARCH", "from", src, "ST", st)
reply := buildMSearchReply(udn, location, st)
_, _ = conn.WriteToUDP([]byte(reply), src)
}
}
// multicastInterfaces returns all UP interfaces that support multicast.
func multicastInterfaces() ([]*net.Interface, error) {
all, err := net.Interfaces()
if err != nil {
return nil, err
}
var result []*net.Interface
for i := range all {
iface := &all[i]
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagMulticast == 0 {
continue
}
result = append(result, iface)
}
return result, nil
}
// stMatches returns true when the ST header should receive an M-SEARCH reply.
func stMatches(st string) bool {
switch st {
case "ssdp:all", "upnp:rootdevice", mediaServerURN:
return true
}
return false
}
// buildMSearchReply builds an HTTP/1.1 200 OK SSDP response.
func buildMSearchReply(udn, location, st string) string {
usn := usnForST(udn, st)
return fmt.Sprintf(
"HTTP/1.1 200 OK\r\n"+
"CACHE-CONTROL: max-age=%d\r\n"+
"DATE: %s\r\n"+
"EXT:\r\n"+
"LOCATION: %s\r\n"+
"SERVER: %s\r\n"+
"ST: %s\r\n"+
"USN: %s\r\n"+
"\r\n",
ssdpMaxAge,
time.Now().UTC().Format(http.TimeFormat),
location,
serverVersion,
st,
usn,
)
}
// usnForST builds the USN header value for a given ST.
func usnForST(udn, st string) string {
if st == "upnp:rootdevice" || st == "ssdp:all" {
return udn + "::upnp:rootdevice"
}
return udn + "::" + st
}
// ----------------------------------------------------------------------------
// SSDP alive announcements
// ----------------------------------------------------------------------------
func runSSDPAlive(ctx context.Context, logger *slog.Logger, udn, location string) {
// Send an initial batch immediately, then repeat on the interval.
sendAlive(logger, udn, location)
ticker := time.NewTicker(notifyInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
sendAlive(logger, udn, location)
}
}
}
func sendAlive(logger *slog.Logger, udn, location string) {
nts := []struct{ nt, usn string }{
{"upnp:rootdevice", udn + "::upnp:rootdevice"},
{udn, udn},
{mediaServerURN, udn + "::" + mediaServerURN},
{contentDirURN, udn + "::" + contentDirURN},
}
conn, err := net.Dial("udp4", ssdpMulticastAddr)
if err != nil {
logger.Warn("SSDP: cannot send alive notification", "err", err)
return
}
defer conn.Close()
for _, n := range nts {
msg := fmt.Sprintf(
"NOTIFY * HTTP/1.1\r\n"+
"HOST: %s\r\n"+
"CACHE-CONTROL: max-age=%d\r\n"+
"LOCATION: %s\r\n"+
"NT: %s\r\n"+
"NTS: ssdp:alive\r\n"+
"SERVER: %s\r\n"+
"USN: %s\r\n"+
"\r\n",
ssdpMulticastAddr, ssdpMaxAge, location,
n.nt, serverVersion, n.usn,
)
_, _ = conn.Write([]byte(msg))
}
logger.Debug("SSDP: alive announcements sent")
}
// ----------------------------------------------------------------------------
// SSDP byebye on shutdown
// ----------------------------------------------------------------------------
func sendByebye(logger *slog.Logger, udn string) {
conn, err := net.Dial("udp4", ssdpMulticastAddr)
if err != nil {
logger.Warn("SSDP: cannot send byebye", "err", err)
return
}
defer conn.Close()
nts := []struct{ nt, usn string }{
{"upnp:rootdevice", udn + "::upnp:rootdevice"},
{udn, udn},
{mediaServerURN, udn + "::" + mediaServerURN},
{contentDirURN, udn + "::" + contentDirURN},
}
for _, n := range nts {
msg := fmt.Sprintf(
"NOTIFY * HTTP/1.1\r\n"+
"HOST: %s\r\n"+
"NT: %s\r\n"+
"NTS: ssdp:byebye\r\n"+
"USN: %s\r\n"+
"\r\n",
ssdpMulticastAddr, n.nt, n.usn,
)
_, _ = conn.Write([]byte(msg))
}
logger.Info("SSDP: byebye announcements sent")
}
// ----------------------------------------------------------------------------
// Network helpers
// ----------------------------------------------------------------------------
// primaryLANIP returns the first non-loopback, non-link-local IPv4 address
// found on any UP interface.
func primaryLANIP() (string, error) {
ifaces, err := net.Interfaces()
if err != nil {
return "", err
}
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok {
continue
}
v4 := ipNet.IP.To4()
if v4 == nil {
continue
}
if v4.IsLoopback() || v4.IsLinkLocalUnicast() {
continue
}
return v4.String(), nil
}
}
return "", fmt.Errorf("no usable LAN IPv4 address found")
}
// extractHeader extracts a header value from a raw HTTP-style SSDP message.
// Key comparison is case-insensitive.
func extractHeader(msg, key string) string {
lower := strings.ToLower(key) + ":"
for _, line := range strings.Split(msg, "\n") {
trimmed := strings.TrimRight(line, "\r")
if strings.HasPrefix(strings.ToLower(trimmed), lower) {
return strings.TrimSpace(trimmed[len(lower):])
}
}
return ""
}
+625
View File
@@ -0,0 +1,625 @@
// Package dlnatest provides an in-process DLNA / UPnP MediaServer for use in
// unit tests (via httptest.Server) and as a real LAN-visible server.
//
// The server handles:
// - GET /rootDesc.xml device description (UPnP root device)
// - POST /ctl/ContentDir ContentDirectory Browse SOAP action
// - GET /MediaItems/*.wav synthesised audio bytes (1 s silent WAV)
// - GET /icons/sm.png minimal 1x1 PNG so icon fetches do not 404
//
// All absolute URLs in DIDL-Lite <res> elements are built from the
// incoming request's Host header, so the same handler works unchanged
// behind httptest.Server and a real net.Listener.
package dlnatest
import (
"encoding/xml"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
)
// ----------------------------------------------------------------------------
// Content tree model
// ----------------------------------------------------------------------------
// Container represents a DLNA object.container node.
type Container struct {
ID string
ParentID string
Title string
Class string // upnp:class value, e.g. "object.container.storageFolder"
Children []*Item
}
// Item represents a DLNA object.item.audioItem node.
type Item struct {
ID string
ParentID string
Title string
Class string // upnp:class value, e.g. "object.item.audioItem.musicTrack"
Artist string
Album string
MimeType string
DurSec float64 // duration in seconds
Payload []byte // raw audio bytes served at /MediaItems/<ID>.<ext>
}
// mediaExt returns the file extension for this item's MIME type.
func (it *Item) mediaExt() string {
switch it.MimeType {
case "audio/x-wav", "audio/wav":
return "wav"
default:
return "bin"
}
}
// Tree is the in-memory content tree. Root containers are stored by ID.
type Tree struct {
Containers []*Container // ordered; first container is the default music folder
}
// DefaultTree returns a minimal two-track music library that matches the
// structure used in the spec/capture comments.
func DefaultTree() *Tree {
track01 := silentWAV(1, 8000, 1)
track02 := silentWAV(1, 8000, 1)
music := &Container{
ID: "1",
ParentID: "0",
Title: "Music",
Class: "object.container.storageFolder",
Children: []*Item{
{
ID: "1$4$0",
ParentID: "1$4",
Title: "track01",
Class: "object.item.audioItem.musicTrack",
MimeType: "audio/x-wav",
DurSec: 1.0,
Payload: track01,
},
{
ID: "1$4$1",
ParentID: "1$4",
Title: "track02",
Class: "object.item.audioItem.musicTrack",
MimeType: "audio/x-wav",
DurSec: 1.0,
Payload: track02,
},
},
}
return &Tree{Containers: []*Container{music}}
}
// containerByID returns the container with the given ID, or nil.
func (t *Tree) containerByID(id string) *Container {
for _, c := range t.Containers {
if c.ID == id {
return c
}
}
return nil
}
// itemByID returns the first item in any container whose ID matches.
func (t *Tree) itemByID(id string) *Item {
for _, c := range t.Containers {
for _, it := range c.Children {
if it.ID == id {
return it
}
}
}
return nil
}
// ----------------------------------------------------------------------------
// Server
// ----------------------------------------------------------------------------
// Option is a functional option for NewServer.
type Option func(*Server)
// WithFriendlyName overrides the UPnP friendlyName.
func WithFriendlyName(name string) Option {
return func(s *Server) { s.FriendlyName = name }
}
// WithUDN overrides the UPnP Unique Device Name (UUID).
func WithUDN(udn string) Option {
return func(s *Server) { s.UDN = udn }
}
// WithTree replaces the entire content tree.
func WithTree(tree *Tree) Option {
return func(s *Server) { s.tree = tree }
}
// Server is the DLNA / UPnP MediaServer implementation.
type Server struct {
FriendlyName string
UDN string
tree *Tree
}
// NewServer creates a Server with the supplied options applied.
func NewServer(opts ...Option) *Server {
s := &Server{
FriendlyName: "AfterTouch Test Library",
UDN: "uuid:4d696e69-444c-164e-9d41-72ecda78e4c1",
tree: DefaultTree(),
}
for _, o := range opts {
o(s)
}
return s
}
// NewHTTPTest starts an httptest.Server backed by s and returns both.
// Call ts.Close() when the test is done.
func NewHTTPTest(opts ...Option) (*httptest.Server, *Server) {
s := NewServer(opts...)
ts := httptest.NewServer(s.HTTPHandler())
return ts, s
}
// HTTPHandler returns an http.Handler that serves all DLNA endpoints.
func (s *Server) HTTPHandler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/rootDesc.xml", s.serveRootDesc)
mux.HandleFunc("/ctl/ContentDir", s.serveContentDir)
mux.HandleFunc("/icons/sm.png", s.serveIcon)
mux.HandleFunc("/MediaItems/", s.serveMediaItem)
return mux
}
// ----------------------------------------------------------------------------
// /rootDesc.xml
// ----------------------------------------------------------------------------
func (s *Server) serveRootDesc(w http.ResponseWriter, _ *http.Request) {
type specVersion struct {
Major int `xml:"major"`
Minor int `xml:"minor"`
}
type icon struct {
MimeType string `xml:"mimetype"`
Width int `xml:"width"`
Height int `xml:"height"`
Depth int `xml:"depth"`
URL string `xml:"url"`
}
type service struct {
ServiceType string `xml:"serviceType"`
ServiceID string `xml:"serviceId"`
ControlURL string `xml:"controlURL"`
EventSubURL string `xml:"eventSubURL,omitempty"`
SCPDURL string `xml:"SCPDURL,omitempty"`
}
type device struct {
DeviceType string `xml:"deviceType"`
FriendlyName string `xml:"friendlyName"`
Manufacturer string `xml:"manufacturer"`
ModelName string `xml:"modelName"`
ModelNumber string `xml:"modelNumber"`
SerialNumber string `xml:"serialNumber"`
UDN string `xml:"UDN"`
IconList []icon `xml:"iconList>icon"`
ServiceList []service `xml:"serviceList>service"`
}
type rootDesc struct {
XMLName xml.Name `xml:"urn:schemas-upnp-org:device-1-0 root"`
SpecVersion specVersion `xml:"specVersion"`
Device device `xml:"device"`
}
desc := rootDesc{
SpecVersion: specVersion{Major: 1, Minor: 0},
Device: device{
DeviceType: "urn:schemas-upnp-org:device:MediaServer:1",
FriendlyName: s.FriendlyName,
Manufacturer: "AfterTouch",
ModelName: "AfterTouch Test MediaServer",
ModelNumber: "1",
SerialNumber: "00000000",
UDN: s.UDN,
IconList: []icon{
{MimeType: "image/png", Width: 48, Height: 48, Depth: 24, URL: "/icons/sm.png"},
},
ServiceList: []service{
{
ServiceType: "urn:schemas-upnp-org:service:ContentDirectory:1",
ServiceID: "urn:upnp-org:serviceId:ContentDirectory",
ControlURL: "/ctl/ContentDir",
EventSubURL: "/evt/ContentDir",
SCPDURL: "/ContentDir.xml",
},
{
ServiceType: "urn:schemas-upnp-org:service:ConnectionManager:1",
ServiceID: "urn:upnp-org:serviceId:ConnectionManager",
ControlURL: "/ctl/ConnectionMgr",
},
},
},
}
w.Header().Set("Content-Type", "text/xml; charset=utf-8")
if _, err := fmt.Fprint(w, xml.Header); err != nil {
http.Error(w, "write error", http.StatusInternalServerError)
return
}
enc := xml.NewEncoder(w)
enc.Indent("", "")
if err := enc.Encode(desc); err != nil {
// Headers already sent; best effort.
return
}
}
// ----------------------------------------------------------------------------
// /ctl/ContentDir (ContentDirectory Browse SOAP action)
// ----------------------------------------------------------------------------
// soapBrowseRequest is the envelope we parse from the incoming POST.
type soapBrowseRequest struct {
Body struct {
Browse struct {
ObjectID string `xml:"ObjectID"`
BrowseFlag string `xml:"BrowseFlag"`
StartingIndex int `xml:"StartingIndex"`
RequestedCount int `xml:"RequestedCount"`
} `xml:"Browse"`
} `xml:"Body"`
}
func (s *Server) serveContentDir(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse the SOAP envelope (lenient: ignore namespace prefixes via xml.Unmarshal).
var req soapBrowseRequest
if err := xml.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad soap envelope", http.StatusBadRequest)
return
}
objectID := req.Body.Browse.ObjectID
startIndex := req.Body.Browse.StartingIndex
reqCount := req.Body.Browse.RequestedCount
base := baseURL(r)
var didl string
var total int
switch objectID {
case "0":
// Root: return containers.
didl, total = s.browseRoot(startIndex, reqCount)
default:
// Try as a container ID.
if c := s.tree.containerByID(objectID); c != nil {
didl, total = s.browseContainer(c, startIndex, reqCount, base)
} else {
// Unknown object: return empty result.
didl = emptyDIDL()
total = 0
}
}
// NumberReturned is the count of items in this page.
var returned int
if reqCount <= 0 || reqCount > total-startIndex {
returned = total - startIndex
} else {
returned = reqCount
}
if returned < 0 {
returned = 0
}
writeSOAPBrowseResponse(w, didl, returned, total)
}
// baseURL builds an absolute http://host:port prefix from the request.
func baseURL(r *http.Request) string {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
return scheme + "://" + r.Host
}
// browseRoot returns DIDL-Lite for the root container (ObjectID "0").
func (s *Server) browseRoot(start, count int) (string, int) {
containers := s.tree.Containers
total := len(containers)
page := page(containers, start, count)
var b strings.Builder
b.WriteString(`<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" `)
b.WriteString(`xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" `)
b.WriteString(`xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" `)
b.WriteString(`xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/">`)
for _, c := range page {
childCount := len(c.Children)
_, _ = fmt.Fprintf(&b,
`<container id=%s parentID=%s restricted="1" childCount="%d">`,
xmlAttr(c.ID), xmlAttr(c.ParentID), childCount,
)
b.WriteString(`<dc:title>` + xmlEsc(c.Title) + `</dc:title>`)
b.WriteString(`<upnp:class>` + xmlEsc(c.Class) + `</upnp:class>`)
b.WriteString(`</container>`)
}
b.WriteString(`</DIDL-Lite>`)
return b.String(), total
}
// browseContainer returns DIDL-Lite for the items inside a container.
func (s *Server) browseContainer(c *Container, start, count int, base string) (string, int) {
items := c.Children
total := len(items)
pageItems := pageItems(items, start, count)
var b strings.Builder
b.WriteString(`<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" `)
b.WriteString(`xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" `)
b.WriteString(`xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" `)
b.WriteString(`xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/">`)
for _, it := range pageItems {
size := len(it.Payload)
dur := formatDuration(it.DurSec)
resURL := fmt.Sprintf("%s/MediaItems/%s.%s", base, urlPathEsc(it.ID), it.mediaExt())
_, _ = fmt.Fprintf(&b,
`<item id=%s parentID=%s restricted="1">`,
xmlAttr(it.ID), xmlAttr(it.ParentID),
)
b.WriteString(`<dc:title>` + xmlEsc(it.Title) + `</dc:title>`)
if it.Artist != "" {
b.WriteString(`<dc:creator>` + xmlEsc(it.Artist) + `</dc:creator>`)
}
b.WriteString(`<upnp:class>` + xmlEsc(it.Class) + `</upnp:class>`)
_, _ = fmt.Fprintf(&b,
`<res size="%d" duration="%s" bitrate="128000" sampleFrequency="8000" nrAudioChannels="1" protocolInfo="http-get:*:%s:*">%s</res>`,
size, dur, xmlEsc(it.MimeType), xmlEsc(resURL),
)
b.WriteString(`</item>`)
}
b.WriteString(`</DIDL-Lite>`)
return b.String(), total
}
func emptyDIDL() string {
return `<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" ` +
`xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" ` +
`xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" ` +
`xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/"></DIDL-Lite>`
}
// writeSOAPBrowseResponse writes the full SOAP envelope around the DIDL-Lite result.
func writeSOAPBrowseResponse(w http.ResponseWriter, didl string, returned, total int) {
w.Header().Set("Content-Type", "text/xml; charset=utf-8")
// The DIDL-Lite result must appear as XML-escaped text inside the <Result> element.
escaped := xmlEsc(didl)
body := `<?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:BrowseResponse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1">` +
`<Result>` + escaped + `</Result>` +
`<NumberReturned>` + strconv.Itoa(returned) + `</NumberReturned>` +
`<TotalMatches>` + strconv.Itoa(total) + `</TotalMatches>` +
`<UpdateID>0</UpdateID>` +
`</u:BrowseResponse>` +
`</s:Body>` +
`</s:Envelope>`
_, _ = fmt.Fprint(w, body)
}
// ----------------------------------------------------------------------------
// /MediaItems/<id>.<ext>
// ----------------------------------------------------------------------------
func (s *Server) serveMediaItem(w http.ResponseWriter, r *http.Request) {
// Path: /MediaItems/<id>.<ext>
rel := strings.TrimPrefix(r.URL.Path, "/MediaItems/")
// Strip extension.
dot := strings.LastIndexByte(rel, '.')
id := rel
if dot >= 0 {
id = rel[:dot]
}
// The ID may contain '$' which is percent-encoded in URLs.
// url.PathUnescape would normally handle this, but the mux already decoded it.
item := s.tree.itemByID(id)
if item == nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", item.MimeType)
w.Header().Set("Content-Length", strconv.Itoa(len(item.Payload)))
_, _ = w.Write(item.Payload)
}
// ----------------------------------------------------------------------------
// /icons/sm.png
// ----------------------------------------------------------------------------
// tinyPNG is a 1x1 white pixel PNG (67 bytes, entirely static).
var tinyPNG = []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR length + type
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // width=1, height=1
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, // bit depth=8, color=RGB, ...
0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, // IHDR CRC; IDAT length + type
0x54, 0x08, 0xd7, 0x63, 0xf8, 0xff, 0xff, 0x3f, // IDAT data (deflate)
0x00, 0x05, 0xfe, 0x02, 0xfe, 0xdc, 0xcc, 0x59, // IDAT continued
0xe7, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, // IDAT CRC; IEND length + type
0x44, 0xae, 0x42, 0x60, 0x82, // IEND CRC
}
func (s *Server) serveIcon(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Content-Length", strconv.Itoa(len(tinyPNG)))
_, _ = w.Write(tinyPNG)
}
// ----------------------------------------------------------------------------
// WAV synthesis
// ----------------------------------------------------------------------------
// silentWAV generates a minimal PCM WAV file: mono, 16-bit, given sample rate
// and duration in seconds. All samples are zero (silence).
func silentWAV(durationSec float64, sampleRate, channels int) []byte {
numSamples := int(float64(sampleRate) * durationSec)
bitsPerSample := 16
byteRate := sampleRate * channels * bitsPerSample / 8
blockAlign := channels * bitsPerSample / 8
dataSize := numSamples * blockAlign
fileSize := 36 + dataSize
buf := make([]byte, 44+dataSize)
// RIFF header
copy(buf[0:], "RIFF")
le32(buf[4:], uint32(fileSize))
copy(buf[8:], "WAVE")
// fmt chunk
copy(buf[12:], "fmt ")
le32(buf[16:], 16) // chunk size
le16(buf[20:], 1) // PCM
le16(buf[22:], uint16(channels))
le32(buf[24:], uint32(sampleRate))
le32(buf[28:], uint32(byteRate))
le16(buf[32:], uint16(blockAlign))
le16(buf[34:], uint16(bitsPerSample))
// data chunk
copy(buf[36:], "data")
le32(buf[40:], uint32(dataSize))
// samples are already zero
return buf
}
func le16(b []byte, v uint16) {
b[0] = byte(v)
b[1] = byte(v >> 8)
}
func le32(b []byte, v uint32) {
b[0] = byte(v)
b[1] = byte(v >> 8)
b[2] = byte(v >> 16)
b[3] = byte(v >> 24)
}
// ----------------------------------------------------------------------------
// Utility helpers
// ----------------------------------------------------------------------------
// xmlEsc escapes s for use as XML text content.
func xmlEsc(s string) string {
var b strings.Builder
xml.EscapeText(&b, []byte(s)) //nolint:errcheck // strings.Builder never errors
return b.String()
}
// xmlAttr returns s as a double-quoted XML attribute value with proper escaping.
func xmlAttr(s string) string {
return `"` + xmlEsc(s) + `"`
}
// urlPathEsc percent-encodes characters that are not safe in a URL path
// segment. We only need to encode '$' (which appears in item IDs).
func urlPathEsc(s string) string {
return strings.ReplaceAll(s, "$", "%24")
}
// formatDuration converts seconds to "h:mm:ss.mmm" as used in DIDL-Lite.
func formatDuration(sec float64) string {
ms := int(sec * 1000)
h := ms / 3600000
ms -= h * 3600000
m := ms / 60000
ms -= m * 60000
s := ms / 1000
ms -= s * 1000
return fmt.Sprintf("%d:%02d:%02d.%03d", h, m, s, ms)
}
// page returns a slice of containers for the requested page.
func page(containers []*Container, start, count int) []*Container {
if start >= len(containers) {
return nil
}
end := len(containers)
if count > 0 && start+count < end {
end = start + count
}
return containers[start:end]
}
// pageItems returns a slice of items for the requested page.
func pageItems(items []*Item, start, count int) []*Item {
if start >= len(items) {
return nil
}
end := len(items)
if count > 0 && start+count < end {
end = start + count
}
return items[start:end]
}
+365
View File
@@ -0,0 +1,365 @@
package dlnatest_test
import (
"encoding/xml"
"io"
"net/http"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/dlna/dlnatest"
)
// ----------------------------------------------------------------------------
// rootDesc.xml
// ----------------------------------------------------------------------------
func TestRootDesc_Parses(t *testing.T) {
ts, _ := dlnatest.NewHTTPTest()
defer ts.Close()
resp, err := http.Get(ts.URL + "/rootDesc.xml")
if err != nil {
t.Fatalf("GET /rootDesc.xml: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("unexpected status %d", resp.StatusCode)
}
var root struct {
XMLName xml.Name `xml:"root"`
Device struct {
DeviceType string `xml:"deviceType"`
FriendlyName string `xml:"friendlyName"`
ServiceList []struct {
ServiceType string `xml:"serviceType"`
ServiceID string `xml:"serviceId"`
ControlURL string `xml:"controlURL"`
} `xml:"serviceList>service"`
} `xml:"device"`
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("reading body: %v", err)
}
if err := xml.Unmarshal(body, &root); err != nil {
t.Fatalf("xml.Unmarshal: %v\nbody: %s", err, body)
}
wantType := "urn:schemas-upnp-org:device:MediaServer:1"
if root.Device.DeviceType != wantType {
t.Errorf("deviceType = %q, want %q", root.Device.DeviceType, wantType)
}
if root.Device.FriendlyName == "" {
t.Error("friendlyName is empty")
}
// Find ContentDirectory service.
var cdControlURL string
for _, svc := range root.Device.ServiceList {
if svc.ServiceType == "urn:schemas-upnp-org:service:ContentDirectory:1" {
cdControlURL = svc.ControlURL
}
}
if cdControlURL == "" {
t.Fatal("ContentDirectory service not found in rootDesc")
}
if cdControlURL != "/ctl/ContentDir" {
t.Errorf("ContentDirectory controlURL = %q, want %q", cdControlURL, "/ctl/ContentDir")
}
}
// ----------------------------------------------------------------------------
// ContentDirectory Browse
// ----------------------------------------------------------------------------
// soapBrowse sends a SOAP Browse request for the given ObjectID and returns
// the raw <Result> string and the parsed DIDL-Lite document.
func soapBrowse(t *testing.T, baseURL, objectID string) (resultRaw string, didl didlLite) {
t.Helper()
body := `<?xml version="1.0" encoding="utf-8"?>` +
`<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">` +
`<s:Body>` +
`<u:Browse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1">` +
`<ObjectID>` + objectID + `</ObjectID>` +
`<BrowseFlag>BrowseDirectChildren</BrowseFlag>` +
`<StartingIndex>0</StartingIndex>` +
`<RequestedCount>0</RequestedCount>` +
`</u:Browse>` +
`</s:Body>` +
`</s:Envelope>`
req, err := http.NewRequest(http.MethodPost, baseURL+"/ctl/ContentDir", strings.NewReader(body))
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
req.Header.Set("Content-Type", "text/xml; charset=utf-8")
req.Header.Set("SOAPACTION", `"urn:schemas-upnp-org:service:ContentDirectory:1#Browse"`)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("POST /ctl/ContentDir: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("unexpected status %d", resp.StatusCode)
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("reading body: %v", err)
}
// Parse the SOAP envelope.
var envelope struct {
Body struct {
BrowseResponse struct {
Result string `xml:"Result"`
NumberReturned int `xml:"NumberReturned"`
TotalMatches int `xml:"TotalMatches"`
} `xml:"BrowseResponse"`
} `xml:"Body"`
}
if err := xml.Unmarshal(raw, &envelope); err != nil {
t.Fatalf("xml.Unmarshal SOAP: %v\nraw: %s", err, raw)
}
resultRaw = envelope.Body.BrowseResponse.Result
// The Result is XML-escaped DIDL-Lite; unescape + parse.
if err := xml.Unmarshal([]byte(resultRaw), &didl); err != nil {
t.Fatalf("xml.Unmarshal DIDL-Lite: %v\nresult: %s", err, resultRaw)
}
return resultRaw, didl
}
// didlLite is a minimal parse target for DIDL-Lite responses.
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 string `xml:"childCount,attr"`
Title string `xml:"title"`
Class string `xml:"class"`
}
type didlItem struct {
ID string `xml:"id,attr"`
ParentID string `xml:"parentID,attr"`
Title string `xml:"title"`
Class string `xml:"class"`
Res []didlRes `xml:"res"`
}
type didlRes struct {
ProtocolInfo string `xml:"protocolInfo,attr"`
URL string `xml:",chardata"`
}
func TestBrowseRoot_ReturnsMusicContainer(t *testing.T) {
ts, _ := dlnatest.NewHTTPTest()
defer ts.Close()
_, didl := soapBrowse(t, ts.URL, "0")
if len(didl.Containers) == 0 {
t.Fatal("Browse root returned no containers")
}
var found bool
for _, c := range didl.Containers {
if c.Title == "Music" {
found = true
if c.ID == "" {
t.Error("Music container has empty id")
}
if c.ParentID != "0" {
t.Errorf("Music container parentID = %q, want \"0\"", c.ParentID)
}
}
}
if !found {
t.Errorf("no Music container in root browse; got containers: %v", didl.Containers)
}
}
func TestBrowseMusicFolder_ReturnsTwoItems(t *testing.T) {
ts, _ := dlnatest.NewHTTPTest()
defer ts.Close()
// First get the Music container ID from root.
_, rootDIDL := soapBrowse(t, ts.URL, "0")
var musicID string
for _, c := range rootDIDL.Containers {
if c.Title == "Music" {
musicID = c.ID
}
}
if musicID == "" {
t.Fatal("could not find Music container in root browse")
}
_, didl := soapBrowse(t, ts.URL, musicID)
if len(didl.Items) != 2 {
t.Fatalf("expected 2 audio items in Music folder, got %d", len(didl.Items))
}
for _, item := range didl.Items {
if item.Title == "" {
t.Error("item has empty title")
}
if len(item.Res) == 0 {
t.Errorf("item %q has no <res> element", item.Title)
continue
}
resURL := strings.TrimSpace(item.Res[0].URL)
if resURL == "" {
t.Errorf("item %q has empty <res> URL", item.Title)
continue
}
// Verify the resource URL is fetchable and returns audio bytes.
t.Run("fetch_"+item.Title, func(t *testing.T) {
resp, err := http.Get(resURL)
if err != nil {
t.Fatalf("GET %s: %v", resURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET %s: status %d", resURL, resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("reading 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 header.
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])
}
})
}
}
// ----------------------------------------------------------------------------
// Icon
// ----------------------------------------------------------------------------
func TestIcon_ReturnsPNG(t *testing.T) {
ts, _ := dlnatest.NewHTTPTest()
defer ts.Close()
resp, err := http.Get(ts.URL + "/icons/sm.png")
if err != nil {
t.Fatalf("GET /icons/sm.png: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("unexpected status %d", resp.StatusCode)
}
ct := resp.Header.Get("Content-Type")
if !strings.Contains(ct, "image/png") {
t.Errorf("Content-Type = %q, want image/png", ct)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("reading icon body: %v", err)
}
// PNG magic bytes.
if len(data) < 8 || string(data[0:4]) != "\x89PNG" {
t.Errorf("response does not look like a PNG (first bytes: %x)", data[:min8(len(data))])
}
}
func min8(n int) int {
if n < 8 {
return n
}
return 8
}
// ----------------------------------------------------------------------------
// Custom tree
// ----------------------------------------------------------------------------
func TestCustomTree(t *testing.T) {
customTree := &dlnatest.Tree{
Containers: []*dlnatest.Container{
{
ID: "99",
ParentID: "0",
Title: "CustomFolder",
Class: "object.container.storageFolder",
Children: []*dlnatest.Item{
{
ID: "99$0",
ParentID: "99",
Title: "custom-track",
Class: "object.item.audioItem.musicTrack",
MimeType: "audio/x-wav",
DurSec: 0.5,
Payload: []byte("RIFF\x00\x00\x00\x00WAVEfmt "),
},
},
},
},
}
ts, _ := dlnatest.NewHTTPTest(dlnatest.WithTree(customTree))
defer ts.Close()
_, didl := soapBrowse(t, ts.URL, "0")
if len(didl.Containers) != 1 || didl.Containers[0].Title != "CustomFolder" {
t.Errorf("custom tree root browse: got %v", didl.Containers)
}
}