From dfa670670324e5b042feb14eda9608a02908278a Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sun, 14 Jun 2026 17:10:24 +0200 Subject: [PATCH] feat(example-dlna-server): album art, real media via --media-dir, fix discovery, BrowseMetadata + access log Tooling to reproduce "album cover broken in the player" (disc #499) and to debug STORED_MUSIC playback against a controllable DLNA source. - dlnatest.Item gains ArtPayload/ArtMime; the Browse DIDL emits and the art bytes are served at /AlbumArt/.. The built-in tracks carry a tiny PNG cover so the repro works with zero setup. - audio + art are served via http.ServeContent (adds the byte-range support real speakers use when streaming). - example-dlna-server gains --media-dir: serve real .mp3/.wav/.flac/.m4a/.ogg files, searched recursively so an artist/album tree works. Art per track: a sibling .jpg/.png, else cover.jpg/cover.png/folder.jpg in the album folder. Files are read into memory (point it at an album, not a whole library). - BrowseMetadata: serveContentDir now honours BrowseFlag and returns single-object metadata (with the track's ). Speakers issue Browse(BrowseMetadata) to resolve a track before playing; returning empty caused INVALID_SOURCE. - fix SSDP discoverability on multi-interface hosts: join the multicast group on the interface that owns the LAN IP (macOS lists lo0 first, so the old "first multicast interface" join landed on loopback and never heard the LAN M-SEARCH). - add an HTTP access log (method/path/status/bytes/peer, plus ObjectID+BrowseFlag for Browse) so the speaker's request sequence is visible while debugging. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/example-dlna-server/main.go | 316 ++++++++++++++++++++++++++++++-- pkg/dlna/dlnatest/dlnatest.go | 250 +++++++++++++++++++------ 2 files changed, 486 insertions(+), 80 deletions(-) diff --git a/cmd/example-dlna-server/main.go b/cmd/example-dlna-server/main.go index 9c88532..aea52c7 100644 --- a/cmd/example-dlna-server/main.go +++ b/cmd/example-dlna-server/main.go @@ -17,14 +17,18 @@ package main import ( + "bytes" "context" "flag" "fmt" + "io" + "io/fs" "log/slog" "net" "net/http" "os" "os/signal" + "path/filepath" "strings" "syscall" "time" @@ -47,6 +51,11 @@ const ( func main() { port := flag.Int("port", 8200, "HTTP port to bind") name := flag.String("name", "AfterTouch Test Library", "UPnP friendlyName advertised over SSDP") + mediaDir := flag.String("media-dir", "", "serve real audio files + artwork from this directory "+ + "(searched recursively, so an artist/album tree works) instead of the built-in silent test "+ + "tracks. Audio: .mp3/.wav/.flac/.m4a/.ogg. Art per track: a sibling .jpg/.png, else a "+ + "cover.jpg/cover.png/folder.jpg in the same album folder. Files are loaded into memory, so "+ + "point it at an album or a modest folder, not your whole library") flag.Parse() @@ -62,11 +71,33 @@ func main() { 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)) + // Join the SSDP multicast group on the interface that owns the LAN IP. On + // macOS net.Interfaces() lists lo0 (UP+MULTICAST) first, so picking the + // "first" multicast interface would join on loopback and never receive the + // LAN M-SEARCH from clients like AfterTouch. + lanIface := interfaceForIP(lanIP) + if lanIface != nil { + logger.Info("SSDP: will join multicast on LAN interface", "iface", lanIface.Name, "ip", lanIP) + } + + opts := []dlnatest.Option{dlnatest.WithFriendlyName(*name)} + + if *mediaDir != "" { + tree, n, err := loadTreeFromDir(*mediaDir, *name) + if err != nil { + logger.Error("failed to load --media-dir", "dir", *mediaDir, "err", err) + os.Exit(1) + } + + opts = append(opts, dlnatest.WithTree(tree)) + logger.Info("serving real media from directory", "dir", *mediaDir, "tracks", n) + } + + srv := dlnatest.NewServer(opts...) httpSrv := &http.Server{ Addr: addr, - Handler: srv.HTTPHandler(), + Handler: withAccessLog(logger, srv.HTTPHandler()), } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) @@ -87,7 +118,7 @@ func main() { udn := srv.UDN // Start SSDP listener + responder. - go runSSDPListener(ctx, logger, udn, location) + go runSSDPListener(ctx, logger, udn, location, lanIface) // Start periodic ssdp:alive announcements. go runSSDPAlive(ctx, logger, udn, location) @@ -116,27 +147,25 @@ func main() { // SSDP listener: answers M-SEARCH requests // ---------------------------------------------------------------------------- -func runSSDPListener(ctx context.Context, logger *slog.Logger, udn, location string) { +func runSSDPListener(ctx context.Context, logger *slog.Logger, udn, location string, ifi *net.Interface) { 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) + // Join the group on the LAN interface. If we could not resolve it, fall back + // to the first non-loopback multicast interface (never loopback, which would + // only ever receive same-host loopback traffic). + if ifi == nil { + if cands, err := multicastInterfaces(); err == nil { + for _, c := range cands { + if c != nil && c.Flags&net.FlagLoopback == 0 { + ifi = c - ifaces = []*net.Interface{nil} // nil = system default + break + } + } + } } - 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) + conn, err := net.ListenMulticastUDP("udp4", ifi, group) if err != nil { logger.Warn("SSDP: ListenMulticastUDP failed (try running as root or check firewall)", "err", err) @@ -385,6 +414,255 @@ func primaryLANIP() (string, error) { return "", fmt.Errorf("no usable LAN IPv4 address found") } +// ---------------------------------------------------------------------------- +// --media-dir loader +// ---------------------------------------------------------------------------- + +// loadTreeFromDir walks dir recursively and builds a single flat content folder +// from every audio file found, so an artist/album tree works. Album art for a +// track is, in order of preference: a sibling ., then a +// cover.jpg/cover.png/folder.jpg in the track's own directory. Returns the tree +// and track count. +func loadTreeFromDir(dir, albumName string) (*dlnatest.Tree, int, error) { + music := &dlnatest.Container{ + ID: "1", + ParentID: "0", + Title: albumName, + Class: "object.container.storageFolder", + } + + // Cache the resolved cover per directory so we read each album's folder.jpg + // once rather than for every track in it. + type cover struct { + data []byte + mime string + } + + coverCache := map[string]cover{} + + dirCover := func(d string) ([]byte, string) { + if c, ok := coverCache[d]; ok { + return c.data, c.mime + } + + var c cover + + for _, n := range []string{"cover.jpg", "cover.jpeg", "cover.png", "folder.jpg", "folder.png", "albumart.jpg", "albumart.png"} { + if b, err := os.ReadFile(filepath.Join(d, n)); err == nil { + c = cover{data: b, mime: imageMimeForExt(filepath.Ext(n))} + + break + } + } + + coverCache[d] = c + + return c.data, c.mime + } + + idx := 0 + + walkErr := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil //nolint:nilerr // skip unreadable entries and directories + } + + mime := audioMimeForExt(filepath.Ext(path)) + if mime == "" { + return nil // not an audio file we recognise + } + + payload, rerr := os.ReadFile(path) + if rerr != nil { + return nil // skip unreadable file + } + + trackDir := filepath.Dir(path) + base := strings.TrimSuffix(d.Name(), filepath.Ext(d.Name())) + + // Prefer a per-track image sibling; fall back to the album-folder cover. + art, artMime := dirCover(trackDir) + + for _, ae := range []string{".jpg", ".jpeg", ".png", ".webp"} { + if b, aerr := os.ReadFile(filepath.Join(trackDir, base+ae)); aerr == nil { + art = b + artMime = imageMimeForExt(ae) + + break + } + } + + music.Children = append(music.Children, &dlnatest.Item{ + ID: fmt.Sprintf("1$4$%d", idx), + ParentID: "1$4", + Title: base, + Class: "object.item.audioItem.musicTrack", + Artist: "Test Artist", + Album: filepath.Base(trackDir), + MimeType: mime, + Payload: payload, + ArtPayload: art, + ArtMime: artMime, + }) + idx++ + + return nil + }) + if walkErr != nil { + return nil, 0, walkErr + } + + if len(music.Children) == 0 { + return nil, 0, fmt.Errorf("no audio files (.mp3/.wav/.flac/.m4a/.ogg) found under %s", dir) + } + + return &dlnatest.Tree{Containers: []*dlnatest.Container{music}}, len(music.Children), nil +} + +// audioMimeForExt maps an audio file extension to a MIME type, or "" if the +// extension is not a recognised audio format. +func audioMimeForExt(ext string) string { + switch strings.ToLower(ext) { + case ".mp3": + return "audio/mpeg" + case ".wav": + return "audio/x-wav" + case ".flac": + return "audio/flac" + case ".m4a", ".mp4": + return "audio/mp4" + case ".ogg": + return "audio/ogg" + } + + return "" +} + +// imageMimeForExt maps an image file extension to a MIME type. +func imageMimeForExt(ext string) string { + switch strings.ToLower(ext) { + case ".jpg", ".jpeg": + return "image/jpeg" + case ".png": + return "image/png" + case ".webp": + return "image/webp" + case ".gif": + return "image/gif" + } + + return "application/octet-stream" +} + +// ---------------------------------------------------------------------------- +// HTTP access logging (debugging aid) +// ---------------------------------------------------------------------------- + +// statusRecorder captures the status code and byte count of a response. +type statusRecorder struct { + http.ResponseWriter + status int + bytes int +} + +func (r *statusRecorder) WriteHeader(code int) { + r.status = code + r.ResponseWriter.WriteHeader(code) +} + +func (r *statusRecorder) Write(b []byte) (int, error) { + n, err := r.ResponseWriter.Write(b) + r.bytes += n + + return n, err +} + +// withAccessLog logs every HTTP request the server handles. For ContentDirectory +// Browse POSTs it also surfaces the ObjectID and BrowseFlag so the speaker's +// browse sequence (and whether it ever resolves a track's metadata) is visible. +func withAccessLog(logger *slog.Logger, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + var browseAttrs []any + + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "ContentDir") { + body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<16)) + _ = r.Body.Close() + r.Body = io.NopCloser(bytes.NewReader(body)) + + browseAttrs = []any{ + "objectID", between(string(body), "", ""), + "browseFlag", between(string(body), "", ""), + } + } + + rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(rec, r) + + attrs := []any{ + "method", r.Method, + "path", r.URL.Path, + "status", rec.status, + "bytes", rec.bytes, + "from", r.RemoteAddr, + "dur", time.Since(start).String(), + } + attrs = append(attrs, browseAttrs...) + + logger.Info("HTTP", attrs...) + }) +} + +// between returns the text between the first occurrence of open and the next +// close, or "" if not found. Used for lightweight SOAP field extraction in logs. +func between(s, open, close string) string { + i := strings.Index(s, open) + if i < 0 { + return "" + } + + i += len(open) + + j := strings.Index(s[i:], close) + if j < 0 { + return "" + } + + return s[i : i+j] +} + +// interfaceForIP returns the UP, multicast-capable interface that owns the given +// IPv4 address, or nil if none is found. +func interfaceForIP(ip string) *net.Interface { + ifaces, err := net.Interfaces() + if err != nil { + return nil + } + + for i := range ifaces { + iface := &ifaces[i] + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagMulticast == 0 { + continue + } + + addrs, aerr := iface.Addrs() + if aerr != nil { + continue + } + + for _, addr := range addrs { + if ipNet, ok := addr.(*net.IPNet); ok { + if v4 := ipNet.IP.To4(); v4 != nil && v4.String() == ip { + return iface + } + } + } + } + + return nil +} + // extractHeader extracts a header value from a raw HTTP-style SSDP message. // Key comparison is case-insensitive. func extractHeader(msg, key string) string { diff --git a/pkg/dlna/dlnatest/dlnatest.go b/pkg/dlna/dlnatest/dlnatest.go index 64c240a..0f11911 100644 --- a/pkg/dlna/dlnatest/dlnatest.go +++ b/pkg/dlna/dlnatest/dlnatest.go @@ -13,14 +13,20 @@ package dlnatest import ( + "bytes" "encoding/xml" "fmt" "net/http" "net/http/httptest" "strconv" "strings" + "time" ) +// serveModTime is a fixed modification time used for ServeContent so that +// range requests and caching headers behave deterministically. +var serveModTime = time.Unix(1136214245, 0) + // ---------------------------------------------------------------------------- // Content tree model // ---------------------------------------------------------------------------- @@ -45,6 +51,12 @@ type Item struct { MimeType string DurSec float64 // duration in seconds Payload []byte // raw audio bytes served at /MediaItems/. + + // ArtPayload, when non-empty, is album-art image bytes served at + // /AlbumArt/. and advertised in DIDL-Lite via . + // ArtMime is the art image MIME type (e.g. "image/jpeg"). + ArtPayload []byte + ArtMime string } // mediaExt returns the file extension for this item's MIME type. @@ -52,11 +64,35 @@ func (it *Item) mediaExt() string { switch it.MimeType { case "audio/x-wav", "audio/wav": return "wav" + case "audio/mpeg": + return "mp3" + case "audio/flac", "audio/x-flac": + return "flac" + case "audio/mp4", "audio/m4a", "audio/x-m4a": + return "m4a" + case "audio/ogg": + return "ogg" default: return "bin" } } +// artExt returns the file extension for an album-art MIME type. +func artExt(mime string) string { + switch mime { + case "image/jpeg", "image/jpg": + return "jpg" + case "image/png": + return "png" + case "image/webp": + return "webp" + case "image/gif": + return "gif" + default: + return "img" + } +} + // 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 @@ -75,26 +111,30 @@ func DefaultTree() *Tree { Class: "object.container.storageFolder", Children: []*Item{ { - ID: "1$4$0", - 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, + ID: "1$4$0", + 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, + ArtPayload: tinyPNG, + ArtMime: "image/png", }, { - ID: "1$4$1", - 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, + ID: "1$4$1", + 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, + ArtPayload: tinyPNG, + ArtMime: "image/png", }, }, } @@ -186,6 +226,7 @@ func (s *Server) HTTPHandler() http.Handler { mux.HandleFunc("/ctl/ContentDir", s.serveContentDir) mux.HandleFunc("/icons/sm.png", s.serveIcon) mux.HandleFunc("/MediaItems/", s.serveMediaItem) + mux.HandleFunc("/AlbumArt/", s.serveAlbumArt) return mux } @@ -322,18 +363,24 @@ func (s *Server) serveContentDir(w http.ResponseWriter, r *http.Request) { 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 + if req.Body.Browse.BrowseFlag == "BrowseMetadata" { + // Metadata for a single object (the speaker resolves a track's + // this way before playing it). + didl, total = s.browseMetadata(objectID, base) + } else { + 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 + } } } @@ -391,6 +438,44 @@ func (s *Server) browseRoot(start, count int) (string, int) { return b.String(), total } +// didlOpen is the opening tag (with namespaces) shared by all DIDL-Lite results. +const didlOpen = `` + +// writeItemDIDL writes a single DIDL-Lite (title, artist/album, class, +// optional albumArtURI, and the media URL) into b. +func writeItemDIDL(b *strings.Builder, it *Item, base string) { + 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) + ``) + } + + if it.Album != "" { + b.WriteString(`` + xmlEsc(it.Album) + ``) + } + + b.WriteString(`` + xmlEsc(it.Class) + ``) + + if len(it.ArtPayload) > 0 { + artURL := fmt.Sprintf("%s/AlbumArt/%s.%s", base, urlPathEsc(it.ID), artExt(it.ArtMime)) + b.WriteString(`` + xmlEsc(artURL) + ``) + } + + _, _ = fmt.Fprintf(b, + `%s`, + size, dur, xmlEsc(it.MimeType), xmlEsc(resURL), + ) + b.WriteString(``) +} + // 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 @@ -399,36 +484,10 @@ func (s *Server) browseContainer(c *Container, start, count int, base string) (s var b strings.Builder - b.WriteString(``) + b.WriteString(didlOpen) 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) + ``) - } - - if it.Album != "" { - b.WriteString(`` + xmlEsc(it.Album) + ``) - } - - b.WriteString(`` + xmlEsc(it.Class) + ``) - _, _ = fmt.Fprintf(&b, - `%s`, - size, dur, xmlEsc(it.MimeType), xmlEsc(resURL), - ) - b.WriteString(``) + writeItemDIDL(&b, it, base) } b.WriteString(``) @@ -436,6 +495,43 @@ func (s *Server) browseContainer(c *Container, start, count int, base string) (s return b.String(), total } +// browseMetadata returns DIDL-Lite describing a single object (BrowseMetadata), +// which speakers request to resolve a track's URL before playing it. +// Without this, a STORED_MUSIC select of a track ID returns empty metadata and +// the speaker reports INVALID_SOURCE. +func (s *Server) browseMetadata(objectID, base string) (string, int) { + var b strings.Builder + + b.WriteString(didlOpen) + + switch { + case objectID == "0": + _, _ = fmt.Fprintf(&b, + `Rootobject.container.storageFolder`, + len(s.tree.Containers), + ) + case s.tree.containerByID(objectID) != nil: + c := s.tree.containerByID(objectID) + _, _ = fmt.Fprintf(&b, + ``, + xmlAttr(c.ID), xmlAttr(c.ParentID), len(c.Children), + ) + b.WriteString(`` + xmlEsc(c.Title) + ``) + b.WriteString(`` + xmlEsc(c.Class) + ``) + b.WriteString(``) + case s.tree.itemByID(objectID) != nil: + writeItemDIDL(&b, s.tree.itemByID(objectID), base) + default: + b.WriteString(``) + + return b.String(), 0 + } + + b.WriteString(``) + + return b.String(), 1 +} + func emptyDIDL() string { return `. +// ---------------------------------------------------------------------------- + +func (s *Server) serveAlbumArt(w http.ResponseWriter, r *http.Request) { + rel := strings.TrimPrefix(r.URL.Path, "/AlbumArt/") + + dot := strings.LastIndexByte(rel, '.') + id := rel + + if dot >= 0 { + id = rel[:dot] + } + + item := s.tree.itemByID(id) + if item == nil || len(item.ArtPayload) == 0 { + http.NotFound(w, r) + + return + } + + if item.ArtMime != "" { + w.Header().Set("Content-Type", item.ArtMime) + } + + http.ServeContent(w, r, "art."+artExt(item.ArtMime), serveModTime, bytes.NewReader(item.ArtPayload)) } // ----------------------------------------------------------------------------