mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 00:26:29 +00:00
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>
255 lines
6.5 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|