From 11b59d3911af1b8da56bcfdef8e9f57f83c09cb2 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Tue, 9 Jun 2026 20:47:33 +0200 Subject: [PATCH] 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 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) --- .gitignore | 1 + cmd/example-dlna-server/main.go | 401 ++++++++++++++++++ pkg/dlna/dlnatest/dlnatest.go | 625 +++++++++++++++++++++++++++++ pkg/dlna/dlnatest/dlnatest_test.go | 365 +++++++++++++++++ 4 files changed, 1392 insertions(+) create mode 100644 cmd/example-dlna-server/main.go create mode 100644 pkg/dlna/dlnatest/dlnatest.go create mode 100644 pkg/dlna/dlnatest/dlnatest_test.go diff --git a/.gitignore b/.gitignore index 720bcad..891b821 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ dist/ /example-mdns /example-upnp /example-unified +/example-dlna-server /mdns-scanner /websocket-demo /main diff --git a/cmd/example-dlna-server/main.go b/cmd/example-dlna-server/main.go new file mode 100644 index 0000000..9c88532 --- /dev/null +++ b/cmd/example-dlna-server/main.go @@ -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: (default 8200). +// - Detects the host's primary LAN IPv4 to build the SSDP LOCATION header +// and the absolute 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 "" +} diff --git a/pkg/dlna/dlnatest/dlnatest.go b/pkg/dlna/dlnatest/dlnatest.go new file mode 100644 index 0000000..6098131 --- /dev/null +++ b/pkg/dlna/dlnatest/dlnatest.go @@ -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 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/. +} + +// 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(``) + + for _, c := range page { + childCount := len(c.Children) + _, _ = fmt.Fprintf(&b, + ``, + xmlAttr(c.ID), xmlAttr(c.ParentID), childCount, + ) + b.WriteString(`` + xmlEsc(c.Title) + ``) + b.WriteString(`` + xmlEsc(c.Class) + ``) + b.WriteString(``) + } + + b.WriteString(``) + + 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(``) + + 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, + ``, + xmlAttr(it.ID), xmlAttr(it.ParentID), + ) + b.WriteString(`` + xmlEsc(it.Title) + ``) + + if it.Artist != "" { + b.WriteString(`` + xmlEsc(it.Artist) + ``) + } + + b.WriteString(`` + xmlEsc(it.Class) + ``) + _, _ = fmt.Fprintf(&b, + `%s`, + size, dur, xmlEsc(it.MimeType), xmlEsc(resURL), + ) + b.WriteString(``) + } + + b.WriteString(``) + + return b.String(), total +} + +func emptyDIDL() string { + return `` +} + +// 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 element. + escaped := xmlEsc(didl) + + body := `` + + `` + + `` + + `` + + `` + escaped + `` + + `` + strconv.Itoa(returned) + `` + + `` + strconv.Itoa(total) + `` + + `0` + + `` + + `` + + `` + + _, _ = fmt.Fprint(w, body) +} + +// ---------------------------------------------------------------------------- +// /MediaItems/. +// ---------------------------------------------------------------------------- + +func (s *Server) serveMediaItem(w http.ResponseWriter, r *http.Request) { + // Path: /MediaItems/. + 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] +} diff --git a/pkg/dlna/dlnatest/dlnatest_test.go b/pkg/dlna/dlnatest/dlnatest_test.go new file mode 100644 index 0000000..b50ba61 --- /dev/null +++ b/pkg/dlna/dlnatest/dlnatest_test.go @@ -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 string and the parsed DIDL-Lite document. +func soapBrowse(t *testing.T, baseURL, objectID string) (resultRaw string, didl didlLite) { + t.Helper() + + body := `` + + `` + + `` + + `` + + `` + objectID + `` + + `BrowseDirectChildren` + + `0` + + `0` + + `` + + `` + + `` + + 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 element", item.Title) + + continue + } + + resURL := strings.TrimSpace(item.Res[0].URL) + if resURL == "" { + t.Errorf("item %q has empty 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) + } +}