From d4cf5ed8e38d1e5d0b8110e8959e002c216f073f Mon Sep 17 00:00:00 2001 From: xegineering Date: Sun, 17 Nov 2024 12:45:35 +0100 Subject: Fix streaming only via first interface candidate --- CHANGELOG.md | 9 ++++- soundbox/interfaces.go | 39 ------------------ soundbox/ipv6.go | 19 --------- soundbox/ipv6_test.go | 19 --------- soundbox/network.go | 58 +++++++++++++++++++++++++++ soundbox/network_test.go | 19 +++++++++ soundbox/stream.go | 100 ----------------------------------------------- soundbox/stream_test.go | 38 ------------------ soundbox/url.go | 80 +++++++++++++++++++++++++++++++++++++ soundbox/url_test.go | 38 ++++++++++++++++++ 10 files changed, 203 insertions(+), 216 deletions(-) delete mode 100644 soundbox/interfaces.go delete mode 100644 soundbox/ipv6.go delete mode 100644 soundbox/ipv6_test.go create mode 100644 soundbox/network.go create mode 100644 soundbox/network_test.go delete mode 100644 soundbox/stream.go delete mode 100644 soundbox/stream_test.go create mode 100644 soundbox/url.go create mode 100644 soundbox/url_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 5661bf7..b90ce16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,16 @@ The format is based on [Keep a Changelog][keep-a-changelog], and this project adheres to [Semantic Versioning][semantic-versioning]. +## [Unreleased][unreleased] + +### Fixed + +- streaming only via first network interface candidate + + ## [Version 0.1.4][0.1.4] - 2024-11-10 -## Fixed +### Fixed - possible time offsets between soundboxes after longer time diff --git a/soundbox/interfaces.go b/soundbox/interfaces.go deleted file mode 100644 index 04839c7..0000000 --- a/soundbox/interfaces.go +++ /dev/null @@ -1,39 +0,0 @@ -package soundbox - -import ( - "fmt" - "net" -) - -// getInterface is a function guessing which interface should be used for -// soundbox streaming. This is required since soundbox relies on communication -// via IPv6 link-local addresses which require a specified interface to allow -// communication. This function returns the first interface it finds which is -// up and has a link-local address assigned. -func getInterface() (net.Interface, error) { - all, err := net.Interfaces() - if err != nil { - return net.Interface{}, err - } - - for _, iface := range all { - if iface.Flags&net.FlagUp == 0 { - continue - } - addresses, err := iface.Addrs() - if err != nil { - return net.Interface{}, err - } - for _, addr := range addresses { - ip, _, err := net.ParseCIDR(addr.String()) - if err != nil { - return net.Interface{}, err - } - if ip.IsLinkLocalUnicast() { - return iface, nil - } - } - } - - return net.Interface{}, fmt.Errorf("No interface found for soundbox streaming") -} diff --git a/soundbox/ipv6.go b/soundbox/ipv6.go deleted file mode 100644 index 355e233..0000000 --- a/soundbox/ipv6.go +++ /dev/null @@ -1,19 +0,0 @@ -package soundbox - -import ( - "fmt" - "net" -) - -// toLinkLocal converts a MAC address to the corresponding IPv6 link-local -// address. -func toLinkLocal(ha net.HardwareAddr) (net.IP, error) { - switch len(ha) { - case 6: - ip := net.IP{0xfe, 0x80, 0, 0, 0, 0, 0, 0, - ha[0] ^ 0b10, ha[1], ha[2], 0xff, 0xfe, ha[3], ha[4], ha[5]} - return ip, nil - default: - return nil, fmt.Errorf("Only IEEE 802 MAC-48 addresses supported") - } -} diff --git a/soundbox/ipv6_test.go b/soundbox/ipv6_test.go deleted file mode 100644 index 93ac489..0000000 --- a/soundbox/ipv6_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package soundbox - -import ( - "net" - "reflect" - "testing" -) - -func TestToLinkLocal(t *testing.T) { - input := net.HardwareAddr{0xab, 0xcd, 0xef, 0x12, 0x34, 0x56} - expected := net.IP{0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0xa9, 0xcd, 0xef, 0xff, 0xfe, 0x12, 0x34, 0x56} - computed, err := toLinkLocal(input) - if err != nil { - t.Fatalf("Failed to call toLinkLocal(): %v", err) - } - if !reflect.DeepEqual(computed, expected) { - t.Fatalf("Computed IPv6 link-local address %v did not match expectation %v", computed, expected) - } -} diff --git a/soundbox/network.go b/soundbox/network.go new file mode 100644 index 0000000..6e7acea --- /dev/null +++ b/soundbox/network.go @@ -0,0 +1,58 @@ +package soundbox + +import ( + "context" + "fmt" + "net" + "time" +) + +const dialTimeoutSeconds = 3 + +// toLinkLocal converts a MAC address to the corresponding IPv6 link-local +// address. +func toLinkLocal(ha net.HardwareAddr) (net.IP, error) { + switch len(ha) { + case 6: + ip := net.IP{0xfe, 0x80, 0, 0, 0, 0, 0, 0, + ha[0] ^ 0b10, ha[1], ha[2], 0xff, 0xfe, ha[3], ha[4], ha[5]} + return ip, nil + default: + return nil, fmt.Errorf("Only IEEE 802 MAC-48 addresses supported") + } +} + +func dialContext(ctx context.Context, ha net.HardwareAddr) (net.Conn, error) { + ip, err := toLinkLocal(ha) + if err != nil { + return nil, err + } + + ifaces, err := net.Interfaces() + if err != nil { + return nil, err + } + + c := make(chan net.Conn) + dialContext, cancel := context.WithTimeout(ctx, dialTimeoutSeconds * time.Second) + defer cancel() + for _, iface := range ifaces { + go func() { + var d net.Dialer + conn, err := d.DialContext( + ctx, + "tcp6", + fmt.Sprintf("[%s%%%s]:%d", ip, iface.Name, streamingPort), + ) + if err == nil { + c <- conn + } + }() + } + select { + case conn := <-c: + return conn, nil + case <-dialContext.Done(): + return nil, fmt.Errorf("Could not dial TCP connection to %v on port %d on any interface.", ha, streamingPort) + } +} diff --git a/soundbox/network_test.go b/soundbox/network_test.go new file mode 100644 index 0000000..93ac489 --- /dev/null +++ b/soundbox/network_test.go @@ -0,0 +1,19 @@ +package soundbox + +import ( + "net" + "reflect" + "testing" +) + +func TestToLinkLocal(t *testing.T) { + input := net.HardwareAddr{0xab, 0xcd, 0xef, 0x12, 0x34, 0x56} + expected := net.IP{0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0xa9, 0xcd, 0xef, 0xff, 0xfe, 0x12, 0x34, 0x56} + computed, err := toLinkLocal(input) + if err != nil { + t.Fatalf("Failed to call toLinkLocal(): %v", err) + } + if !reflect.DeepEqual(computed, expected) { + t.Fatalf("Computed IPv6 link-local address %v did not match expectation %v", computed, expected) + } +} diff --git a/soundbox/stream.go b/soundbox/stream.go deleted file mode 100644 index 910523d..0000000 --- a/soundbox/stream.go +++ /dev/null @@ -1,100 +0,0 @@ -package soundbox - -import ( - "context" - "errors" - "fmt" - "io" - "net" - "os/exec" - "time" -) - -// streamingPort is the default network port a soundbox is listening to for -// incoming audio stream data. -const streamingPort = 5316 - -const bufferSize = 20 - -const writeTimeout = 1 * time.Second - -// StreamURLContext streams audio from a given URL to one or multiple soundbox -// devices. The devices are referenced via their MAC addresses given by the -// targets argument. The ctx argument is passed to cancel the streaming. -func StreamURLContext(ctx context.Context, url string, targets []net.HardwareAddr) error { - iface, err := getInterface() - if err != nil { - return err - } - - ips := make([]net.IP, 0) - for _, target := range targets { - ip, err := toLinkLocal(target) - if err != nil { - return err - } - ips = append(ips, ip) - } - - conns := make([]net.Conn, 0) - for _, ip := range ips { - var d net.Dialer - conn, err := d.DialContext( - ctx, - "tcp6", - fmt.Sprintf("[%s%%%s]:%d", ip, iface.Name, streamingPort), - ) - if err != nil { - return err - } - conns = append(conns, conn) - } - defer func() { - for _, conn := range conns { - conn.Close() - } - }() - - cmd := exec.CommandContext( - ctx, - "ffmpeg", - "-re", - "-i", - url, - "-acodec", - "flac", - "-f", - "ogg", - "-", - ) - stdout, err := cmd.StdoutPipe() - if err != nil { - return err - } - - err = cmd.Start() - if err != nil { - return err - } - - for { - buffer := make([]byte, bufferSize) - i, err := stdout.Read(buffer) - if err != nil { - if errors.Is(err, io.EOF) { - break - } else { - return err - } - } - for _, conn := range conns { - conn.SetDeadline(time.Now().Add(writeTimeout)) - _, err = conn.Write(buffer[:i]) - if err != nil { - return err - } - } - } - - return cmd.Wait() -} diff --git a/soundbox/stream_test.go b/soundbox/stream_test.go deleted file mode 100644 index 41907ad..0000000 --- a/soundbox/stream_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package soundbox_test - -import ( - "context" - "log" - "net" - "time" - - "xengineering.eu/soundbox-go/soundbox" -) - -func ExampleStreamURLContext() { - ctx, cancel := context.WithCancel(context.Background()) - - // all soundboxes are referenced by their MAC address - soundboxes := []net.HardwareAddr{ - {0x00, 0x00, 0x5E, 0x00, 0x53, 0x01}, - {0x00, 0x00, 0x5E, 0x00, 0x53, 0x02}, - {0x00, 0x00, 0x5E, 0x00, 0x53, 0x03}, - } - - // currently only web radio is supported - url := "https://example.org/radio.mp3" - - // start streaming - go func() { - err := soundbox.StreamURLContext(ctx, url, soundboxes) - if err != nil { - log.Fatal(err) - } - }() - - // let it play for some time - time.Sleep(time.Minute) - - // stop it - cancel() -} diff --git a/soundbox/url.go b/soundbox/url.go new file mode 100644 index 0000000..4ad9908 --- /dev/null +++ b/soundbox/url.go @@ -0,0 +1,80 @@ +package soundbox + +import ( + "context" + "errors" + "io" + "net" + "os/exec" + "time" +) + +// streamingPort is the default network port a soundbox is listening to for +// incoming audio stream data. +const streamingPort = 5316 + +const bufferSize = 20 + +const writeTimeout = 1 * time.Second + +// StreamURLContext streams audio from a given URL to one or multiple soundbox +// devices. The devices are referenced via their MAC addresses given by the +// targets argument. The ctx argument is passed to cancel the streaming. +func StreamURLContext(ctx context.Context, url string, targets []net.HardwareAddr) error { + conns := make([]net.Conn, 0) + for _, target := range targets { + conn, err := dialContext(ctx, target) + if err != nil { + return err + } + conns = append(conns, conn) + } + defer func() { + for _, conn := range conns { + conn.Close() + } + }() + + cmd := exec.CommandContext( + ctx, + "ffmpeg", + "-re", + "-i", + url, + "-acodec", + "flac", + "-f", + "ogg", + "-", + ) + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + + err = cmd.Start() + if err != nil { + return err + } + + for { + buffer := make([]byte, bufferSize) + i, err := stdout.Read(buffer) + if err != nil { + if errors.Is(err, io.EOF) { + break + } else { + return err + } + } + for _, conn := range conns { + conn.SetDeadline(time.Now().Add(writeTimeout)) + _, err = conn.Write(buffer[:i]) + if err != nil { + return err + } + } + } + + return cmd.Wait() +} diff --git a/soundbox/url_test.go b/soundbox/url_test.go new file mode 100644 index 0000000..41907ad --- /dev/null +++ b/soundbox/url_test.go @@ -0,0 +1,38 @@ +package soundbox_test + +import ( + "context" + "log" + "net" + "time" + + "xengineering.eu/soundbox-go/soundbox" +) + +func ExampleStreamURLContext() { + ctx, cancel := context.WithCancel(context.Background()) + + // all soundboxes are referenced by their MAC address + soundboxes := []net.HardwareAddr{ + {0x00, 0x00, 0x5E, 0x00, 0x53, 0x01}, + {0x00, 0x00, 0x5E, 0x00, 0x53, 0x02}, + {0x00, 0x00, 0x5E, 0x00, 0x53, 0x03}, + } + + // currently only web radio is supported + url := "https://example.org/radio.mp3" + + // start streaming + go func() { + err := soundbox.StreamURLContext(ctx, url, soundboxes) + if err != nil { + log.Fatal(err) + } + }() + + // let it play for some time + time.Sleep(time.Minute) + + // stop it + cancel() +} -- cgit v1.2.3-70-g09d2