Files
Bose-SoundTouch/pkg/dlna/browse_test.go
T
Tobias GesellchenandClaude Opus 4.8 ad0f1fbd8f feat(discovery,dlna): generic SSDP core + media-server discovery + browse client
Foundation for browsing DLNA media servers and playing tracks on a
SoundTouch speaker (https://github.com/gesellix/Bose-SoundTouch/discussions/213).

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:50:49 +02:00

255 lines
6.5 KiB
Go

package dlna_test
import (
"context"
"io"
"net/http"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/gesellix/bose-soundtouch/pkg/dlna"
"github.com/gesellix/bose-soundtouch/pkg/dlna/dlnatest"
)
// ----------------------------------------------------------------------------
// Integration tests using the in-process DLNA test fixture
// ----------------------------------------------------------------------------
// TestBrowse_Root checks that Browse("0") returns the Music container from the
// default test tree.
func TestBrowse_Root(t *testing.T) {
ts, _ := dlnatest.NewHTTPTest()
defer ts.Close()
srv := discovery.MediaServer{
FriendlyName: "Test Server",
CDSControlURL: ts.URL + "/ctl/ContentDir",
}
ctx := context.Background()
result, err := dlna.Browse(ctx, srv, "0", 0, 50)
if err != nil {
t.Fatalf("Browse root: %v", err)
}
if len(result.Containers) == 0 {
t.Fatal("Browse root: got 0 containers, want at least 1")
}
var musicContainer *dlna.Container
for i := range result.Containers {
if result.Containers[i].Title == "Music" {
musicContainer = &result.Containers[i]
break
}
}
if musicContainer == nil {
t.Fatalf("Browse root: Music container not found; got %v", result.Containers)
}
if musicContainer.ID == "" {
t.Error("Music container has empty ID")
}
t.Logf("Music container: id=%q parentID=%q childCount=%d",
musicContainer.ID, musicContainer.ParentID, musicContainer.ChildCount)
}
// TestBrowse_MusicFolder checks that browsing into the Music container returns
// exactly 2 audio items with non-empty StreamURLs that are fetchable.
func TestBrowse_MusicFolder(t *testing.T) {
ts, _ := dlnatest.NewHTTPTest()
defer ts.Close()
srv := discovery.MediaServer{
FriendlyName: "Test Server",
CDSControlURL: ts.URL + "/ctl/ContentDir",
}
ctx := context.Background()
// First, browse root to find the Music folder ID.
root, err := dlna.Browse(ctx, srv, "0", 0, 50)
if err != nil {
t.Fatalf("Browse root: %v", err)
}
var musicID string
for _, c := range root.Containers {
if c.Title == "Music" {
musicID = c.ID
break
}
}
if musicID == "" {
t.Fatal("Music container not found in root browse")
}
// Now browse the Music folder.
result, err := dlna.Browse(ctx, srv, musicID, 0, 50)
if err != nil {
t.Fatalf("Browse music folder: %v", err)
}
if len(result.Items) != 2 {
t.Fatalf("expected 2 audio items, got %d", len(result.Items))
}
for _, item := range result.Items {
t.Run(item.Title, func(t *testing.T) {
if item.Title == "" {
t.Error("item has empty Title")
}
if !item.IsAudioItem() {
t.Errorf("IsAudioItem() = false for item %q (MimeType=%q Class=%q)",
item.Title, item.MimeType, item.Class)
}
if item.Artist == "" {
t.Errorf("item %q has empty Artist", item.Title)
} else if item.Artist != "Test Artist" {
t.Errorf("item %q: Artist = %q, want %q", item.Title, item.Artist, "Test Artist")
}
if item.Album == "" {
t.Errorf("item %q has empty Album", item.Title)
} else if item.Album != "Test Album" {
t.Errorf("item %q: Album = %q, want %q", item.Title, item.Album, "Test Album")
}
if item.StreamURL == "" {
t.Fatalf("item %q has empty StreamURL", item.Title)
}
// Fetch the stream URL and verify it returns audio bytes.
resp, err := http.Get(item.StreamURL) //nolint:noctx
if err != nil {
t.Fatalf("GET %s: %v", item.StreamURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET %s: status %d", item.StreamURL, resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read media body: %v", err)
}
if len(data) < 44 {
t.Errorf("audio payload too small (%d bytes), expected at least a WAV header", len(data))
}
// Verify RIFF/WAVE header (silentWAV always produces PCM WAV).
if string(data[0:4]) != "RIFF" {
t.Errorf("expected RIFF header, got %q", data[0:4])
}
if string(data[8:12]) != "WAVE" {
t.Errorf("expected WAVE marker, got %q", data[8:12])
}
})
}
}
// TestBrowse_NoCDSControlURL verifies that Browse returns an error when the
// server has no CDSControlURL set.
func TestBrowse_NoCDSControlURL(t *testing.T) {
srv := discovery.MediaServer{FriendlyName: "Empty"}
_, err := dlna.Browse(context.Background(), srv, "0", 0, 50)
if err == nil {
t.Error("Browse with empty CDSControlURL: expected error, got nil")
}
}
// ----------------------------------------------------------------------------
// Pure function unit tests
// ----------------------------------------------------------------------------
// TestMimeFromProtocolInfo checks the DLNA protocolInfo MIME extraction.
func TestMimeFromProtocolInfo(t *testing.T) {
cases := []struct {
input string
want string
}{
{"http-get:*:audio/x-wav:*", "audio/x-wav"},
{"http-get:*:audio/mpeg:*", "audio/mpeg"},
{"http-get:*:audio/ogg:DLNA.ORG_PN=OGG", "audio/ogg"},
{"http-get:*:image/jpeg:*", "image/jpeg"},
// Fewer than 3 colons: return empty string.
{"http-get", ""},
{"http-get:*", ""},
{"", ""},
}
for _, tc := range cases {
got := dlna.MimeFromProtocolInfo(tc.input)
if got != tc.want {
t.Errorf("MimeFromProtocolInfo(%q) = %q, want %q", tc.input, got, tc.want)
}
}
}
// TestParseHMS checks duration string parsing to seconds.
func TestParseHMS(t *testing.T) {
cases := []struct {
input string
want int
}{
{"0:00:01.000", 1},
{"0:00:01", 1},
{"0:03:42", 222},
{"0:03:42.000", 222},
{"1:00:00", 3600},
{"1:30:00", 5400},
{"0:00:00", 0},
{"", 0},
// Malformed: return 0.
{"99:99", 0},
}
for _, tc := range cases {
got := dlna.ParseHMS(tc.input)
if got != tc.want {
t.Errorf("ParseHMS(%q) = %d, want %d", tc.input, got, tc.want)
}
}
}
// TestIsAudioItem checks the audio-item classifier.
func TestIsAudioItem(t *testing.T) {
cases := []struct {
item dlna.Item
want bool
}{
// MimeType prefix "audio/" is sufficient.
{dlna.Item{MimeType: "audio/x-wav"}, true},
{dlna.Item{MimeType: "audio/mpeg"}, true},
// Class "audioitem" (any case).
{dlna.Item{Class: "object.item.audioItem.musicTrack"}, true},
{dlna.Item{Class: "object.item.musicTrack"}, true},
// Video and image MIME types: not audio.
{dlna.Item{MimeType: "video/mp4"}, false},
{dlna.Item{MimeType: "image/jpeg"}, false},
// Empty item.
{dlna.Item{}, false},
}
for _, tc := range cases {
got := tc.item.IsAudioItem()
if got != tc.want {
t.Errorf("Item{MimeType:%q Class:%q}.IsAudioItem() = %v, want %v",
tc.item.MimeType, tc.item.Class, got, tc.want)
}
}
}