diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2afe3e0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+sblast
+*.zip
+notes
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..37bf13f
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,24 @@
+MIT+NoAI License
+
+Copyright (c) 2024 Uģis Gērmanis
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights/
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+This code may not be used to train artificial intelligence computer models
+or retrieved by artificial intelligence software or hardware.
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..56d5765
--- /dev/null
+++ b/README.md
@@ -0,0 +1,171 @@
+# sblast
+
+
+
+## Cast your Linux audio to DLNA receivers
+
+You need `pactl`, `parec` and `ffmpeg` executables/dependencies on your system to run sblast.
+
+If you have all that then you can launch `sblast` and it looks like this when you run it:
+
+```
+[user@user sblast]$ ./sblast
+----------
+DLNA receivers
+0: Kitchen
+1: Phone
+2: Bedroom
+3: Livingroom TV
+----------
+Select the DLNA device:
+[1]
+----------
+Audio sources
+0: alsa_output.pci-0000_00_1b.0.analog-stereo.monitor
+1: alsa_input.pci-0000_00_1b.0.analog-stereo
+2: bluez_output.D8_AA_59_95_96_B7.1.monitor
+3: sblast.monitor
+----------
+Select the audio source:
+[2]
+----------
+Your LAN ip addresses
+0: 192.168.1.14
+1: 192.168.122.1
+2: 2a04:ec00:b9ab:555:3c50:e6e8:8ea:211f
+3: 2a04:ec00:b9ab:555:806d:800b:1138:8b1b
+4: fe80::f4c2:c827:a865:35e5
+----------
+Select the lan IP address for the stream:
+[0]
+----------
+2023/07/08 23:53:07 starting the stream on port 9000 (configure your firewall if necessary)
+2023/07/10 23:53:07 stream URI: http://192.168.1.14:9000/stream.mp3
+2023/07/08 23:53:07 setting av1transport URI and playing
+```
+
+There are also `-debug` and `-headers` flags if you want to inspect your DLNA device. Also, `-log` to inspect what parec and ffmpeg are doing.
+
+### Non-interactive usage and extra flags
+
+```
+[ugjka@ugjka sblast]$ sblast -h
+Usage of sblast:
+ -bitrate int
+ audio format bitrate (default 320)
+ -bits int
+ audio bitdepth (default 16)
+ -channels int
+ audio channels (default 2)
+ -chunk int
+ chunk size in seconds (default 1)
+ -debug
+ print debug info
+ -device string
+ dlna device's friendly name
+ -dummy
+ only serve content
+ -format string
+ stream audio format (default "mp3")
+ -headers
+ print request headers
+ -ip string
+ host ip address
+ -log
+ log parec and ffmpeg stderr
+ -mime string
+ stream mime type (default "audio/mpeg")
+ -nochunked
+ disable chunked tranfer endcoding
+ -port int
+ stream port (default 9000)
+ -rate int
+ audio sample rate (default 44100)
+ -source string
+ audio source (pactl list sources short | cut -f2)
+ -useaac
+ use aac audio
+ -useflac
+ use flac audio
+ -uselpcm
+ use lpcm audio
+ -uselpcmle
+ use lpcm little-endian audio
+ -usewav
+ use wav audio
+ -version
+ show sblast version
+```
+
+## Tips and tricks
+
+* If you choose `sblast.monitor` as a source, you can send apps' audio to it (in pavucotrol or whatever applet you use) without streaming entire the desktop audio
+
+
+
+* If none of the built-in codecs presets satisfy you, you can specify your own with `-mime` and `-format`. For example: `-mime audio/ac3 -format ac3`, `-mime audio/opus -format opus`, `-mime "audio/x-caf" -format caf` or `-mime "audio/mpeg" -format mp2`
+
+* You can change audio features with `-rate`, `-bits` and `-channels`, e.g. `sblast -rate 48000 -bits 24 -channels 1`
+
+## Building
+
+You need the `go` and `go-tools` toolchain, also `git`
+
+then execute:
+
+```
+git clone https://github.com/ugjka/sblast
+cd sblast
+go build
+```
+
+now you can run sblast with:
+```
+[user@user sblast]$ ./sblast
+```
+
+## Bins
+
+Prebuilt Linux binaries are available on the releases [page](https://github.com/ugjka/sblast/releases)
+
+## Why not use pulseaudio-dlna?
+
+This is for pipewire-pulse users.
+
+## Caveats
+
+* You need to allow port 9000 from LAN for the DLNA receiver to be able to access the HTTP stream, you can change it with `-port` flag
+* sblast monitor sink may not be visible in the pulse control applet unless you enable virtual streams
+
+## Trivia
+
+What on earth is "x-rincon-mp3radio"
+
+## License
+
+```
+MIT+NoAI License
+
+Copyright (c) 2024 Uģis Ģērmanis
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights/
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+This code may not be used to train artificial intelligence computer models
+or retrieved by artificial intelligence software or hardware.
+```
diff --git a/arch/.SRCINFO b/arch/.SRCINFO
new file mode 100644
index 0000000..f93c2c4
--- /dev/null
+++ b/arch/.SRCINFO
@@ -0,0 +1,13 @@
+pkgbase = sblast
+ pkgdesc = send your linux audio to DLNA receivers
+ pkgver = v0.7.0
+ pkgrel = 1
+ url = https://github.com/ugjka/sblast
+ arch = any
+ license = MIT+NoAI
+ makedepends = go
+ makedepends = go-tools
+ source = sblast-v0.7.0.tar.gz::https://github.com/ugjka/sblast/archive/refs/tags/v0.7.0.tar.gz
+ sha256sums = 9c05c0731445b3c4061f3f52bbe4210503ae27e83c15cfcb6121f8dad4dc550f
+
+pkgname = sblast
diff --git a/arch/.gitignore b/arch/.gitignore
new file mode 100644
index 0000000..018a3de
--- /dev/null
+++ b/arch/.gitignore
@@ -0,0 +1,4 @@
+*
+!PKGBUILD
+!.SRCINFO
+!.gitignore
diff --git a/arch/PKGBUILD b/arch/PKGBUILD
new file mode 100644
index 0000000..8a580ba
--- /dev/null
+++ b/arch/PKGBUILD
@@ -0,0 +1,22 @@
+# Maintainer: Uģis Gērmanis
+pkgname=sblast
+pkgver=v0.7.0
+pkgrel=1
+pkgdesc="send your linux audio to DLNA receivers "
+arch=(any)
+url="https://github.com/ugjka/sblast"
+license=('MIT+NoAI')
+makedepends=(go go-tools)
+source=("${pkgname}-${pkgver}.tar.gz::https://github.com/ugjka/${pkgname}/archive/refs/tags/${pkgver}.tar.gz")
+sha256sums=('9c05c0731445b3c4061f3f52bbe4210503ae27e83c15cfcb6121f8dad4dc550f')
+
+build() {
+ cd "${srcdir}/${pkgname}-${pkgver:1}"
+ GOPATH="${srcdir}"/go go build -modcacherw
+}
+
+package() {
+ cd "${srcdir}/${pkgname}-${pkgver:1}"
+ install -Dm755 ${pkgname} "${pkgdir}"/usr/bin/${pkgname}
+ install -Dm644 LICENSE "${pkgdir}"/usr/share/licenses/${pkgname}/LICENSE
+}
diff --git a/audio_serve.go b/audio_serve.go
new file mode 100644
index 0000000..ffbb570
--- /dev/null
+++ b/audio_serve.go
@@ -0,0 +1,232 @@
+// MIT+NoAI License
+//
+// # Copyright (c) 2024 Uģis Ģērmanis
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights///
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+//
+// This code may not be used to train artificial intelligence computer models
+// or retrieved by artificial intelligence software or hardware.
+package main
+
+import (
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "os/exec"
+ "slices"
+ "strings"
+ "sync"
+
+ "github.com/davecgh/go-spew/spew"
+)
+
+type stream struct {
+ sink string
+ mime string
+ format string
+ bitrate int
+ chunk int
+ printheaders bool
+ contentfeat dlnaContentFeatures
+ bitdepth int
+ samplerate int
+ channels int
+ nochunked bool
+ be bool
+}
+
+func (s stream) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if s.printheaders {
+ spew.Fdump(os.Stderr, r.Proto)
+ spew.Fdump(os.Stderr, r.RemoteAddr)
+ spew.Fdump(os.Stderr, r.URL)
+ spew.Fdump(os.Stderr, r.Method)
+ spew.Fdump(os.Stderr, r.Header)
+ }
+ // Set some headers
+ w.Header().Add("Cache-Control", "No-Cache, No-Store")
+ w.Header().Add("Pragma", "No-Cache")
+ w.Header().Add("Expires", "0")
+ w.Header().Add("User-Agent", "sblast-DLNA UPnP/1.0 DLNADOC/1.50")
+ // handle devices like Samsung TVs
+ if r.Header.Get("GetContentFeatures.DLNA.ORG") == "1" {
+ w.Header().Set("ContentFeatures.DLNA.ORG", s.contentfeat.String())
+ }
+
+ var yearSeconds = 365 * 24 * 60 * 60
+ if r.Header.Get("Getmediainfo.sec") == "1" {
+ w.Header().Set("MediaInfo.sec", fmt.Sprintf("SEC_Duration=%d", yearSeconds*1000))
+ }
+ w.Header().Add("Content-Type", s.mime)
+
+ flusher, ok := w.(http.Flusher)
+ chunked := ok && r.Proto == "HTTP/1.1" && !s.nochunked
+
+ if !chunked {
+ size := yearSeconds * (s.bitrate / 8) * 1000
+ if s.bitrate == 0 {
+ size = s.samplerate * s.bitdepth * s.channels * yearSeconds
+ }
+ w.Header().Add(
+ "Content-Length",
+ fmt.Sprint(size),
+ )
+ }
+
+ if r.Method == http.MethodHead {
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ if r.Method != http.MethodGet {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ return
+ }
+ endianess := "le"
+ if s.be {
+ endianess = "be"
+ }
+ parecCMD := exec.Command(
+ "parec",
+ "--device="+s.sink,
+ "--client-name=sblast-rec",
+ "--rate="+fmt.Sprint(s.samplerate),
+ "--channels="+fmt.Sprint(s.channels),
+ "--format="+fmt.Sprintf("s%d%s", s.bitdepth, endianess),
+ "--raw",
+ )
+
+ var raw bool
+ // wav can't have big endian
+ var pcm = fmt.Sprintf("pcm_s%dle", s.bitdepth)
+ if s.format == "lpcm" || s.format == "wav" {
+ raw = true
+ }
+ if s.format == "lpcm" {
+ // lpcm can have big endian
+ s.format = fmt.Sprintf("s%d%s", s.bitdepth, endianess)
+ pcm = fmt.Sprintf("pcm_s%d%s", s.bitdepth, endianess)
+ }
+
+ ffargs := []string{
+ "-f", fmt.Sprintf("s%d%s", s.bitdepth, endianess),
+ "-ac", fmt.Sprint(s.channels),
+ "-ar", fmt.Sprint(s.samplerate),
+ "-i", "-",
+ "-f", s.format, "-",
+ }
+ if s.bitrate != 0 {
+ ffargs = slices.Insert(
+ ffargs,
+ len(ffargs)-3,
+ "-b:a", fmt.Sprintf("%dk", s.bitrate),
+ )
+ }
+ if raw {
+ ffargs = slices.Insert(
+ ffargs,
+ len(ffargs)-1,
+ "-c:a", pcm,
+ )
+ }
+ //spew.Dump(strings.Join(ffargs, " "))
+ ffmpegCMD := exec.Command("ffmpeg", ffargs...)
+
+ if *logsblast {
+ fmt.Fprintln(os.Stderr, strings.Join(parecCMD.Args, " "))
+ parecCMD.Stderr = os.Stderr
+ fmt.Fprintln(os.Stderr, strings.Join(ffmpegCMD.Args, " "))
+ ffmpegCMD.Stderr = os.Stderr
+ }
+
+ parecReader, parecWriter := io.Pipe()
+ parecCMD.Stdout = parecWriter
+ ffmpegCMD.Stdin = parecReader
+
+ ffmpegReader, ffmpegWriter := io.Pipe()
+ ffmpegCMD.Stdout = ffmpegWriter
+
+ var wg sync.WaitGroup
+ //defer fmt.Println("done")
+ defer wg.Wait()
+
+ err := parecCMD.Start()
+ if err != nil {
+ log.Printf("parec failed: %v", err)
+ return
+ }
+ wg.Add(1)
+ go func() {
+ err := parecCMD.Wait()
+ if err != nil && !strings.Contains(err.Error(), "signal") {
+ log.Println("parec:", err)
+ }
+ wg.Done()
+ }()
+
+ err = ffmpegCMD.Start()
+ if err != nil {
+ log.Printf("ffmpeg failed: %v", err)
+ return
+ }
+ wg.Add(1)
+ go func() {
+ err := ffmpegCMD.Wait()
+ if err != nil && !strings.Contains(err.Error(), "signal") {
+ log.Println("ffmpeg:", err)
+ }
+ ffmpegWriter.Close()
+ wg.Done()
+ }()
+ if chunked {
+ var (
+ err error
+ n int
+ )
+ buf := make([]byte, (s.bitrate/8)*1000*s.chunk)
+ if s.bitrate == 0 {
+ buf = make([]byte, s.samplerate*s.bitdepth*s.channels*s.chunk)
+ }
+ for {
+ n, err = ffmpegReader.Read(buf)
+ if err != nil {
+ break
+ }
+ _, err = w.Write(buf[:n])
+ if err != nil {
+ break
+ }
+ flusher.Flush()
+ }
+ } else {
+ io.Copy(w, ffmpegReader)
+ }
+
+ if parecCMD.Process != nil {
+ parecCMD.Process.Kill()
+ }
+ if ffmpegCMD.Process != nil {
+ ffmpegCMD.Process.Kill()
+ }
+ parecReader.Close()
+ parecWriter.Close()
+ ffmpegReader.Close()
+ ffmpegWriter.Close()
+}
diff --git a/audio_source.go b/audio_source.go
new file mode 100644
index 0000000..ee3ecc6
--- /dev/null
+++ b/audio_source.go
@@ -0,0 +1,73 @@
+// MIT+NoAI License
+//
+// # Copyright (c) 2024 Uģis Ģērmanis
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights///
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+//
+// This code may not be used to train artificial intelligence computer models
+// or retrieved by artificial intelligence software or hardware.
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "os/exec"
+)
+
+func chooseAudioSource(lookup string) (string, error) {
+ srcCMD := exec.Command("pactl", "-f", "json", "list", "sources", "short")
+ srcData, err := srcCMD.Output()
+ if err != nil {
+ return "", fmt.Errorf("pactl sources: %v", err)
+ }
+
+ var srcJSON Sources
+ err = json.Unmarshal(srcData, &srcJSON)
+ if err != nil {
+ return "", err
+ }
+ if len(srcJSON) == 0 {
+ return "", fmt.Errorf("no audio sources found")
+ }
+ // append for on-demand loading of sblast sink
+ srcJSON = append(srcJSON, struct{ Name string }{sblastMONITOR})
+ if lookup != "" {
+ for _, v := range srcJSON {
+ if v.Name == lookup {
+ return lookup, nil
+ }
+ }
+ return "", fmt.Errorf("%s: not found", lookup)
+ }
+
+ fmt.Println("Audio sources")
+ for i, v := range srcJSON {
+ fmt.Printf("%d: %s\n", i, v.Name)
+ }
+
+ fmt.Println("----------")
+ fmt.Println("Select the audio source:")
+
+ selected := selector(srcJSON)
+ return srcJSON[selected].Name, nil
+}
+
+type Sources []struct {
+ Name string
+}
diff --git a/avtransport.go b/avtransport.go
new file mode 100644
index 0000000..fb3170e
--- /dev/null
+++ b/avtransport.go
@@ -0,0 +1,161 @@
+// MIT+NoAI License
+//
+// # Copyright (c) 2024 Uģis Ģērmanis
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights///
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+//
+// This code may not be used to train artificial intelligence computer models
+// or retrieved by artificial intelligence software or hardware.
+package main
+
+import (
+ "fmt"
+ "log"
+ "strings"
+ "time"
+
+ "github.com/huin/goupnp"
+ "github.com/huin/goupnp/dcps/av1"
+)
+
+type avsetup struct {
+ device *goupnp.MaybeRootDevice
+ stream stream
+ logoURI string
+ streamURI string
+}
+
+type avtransport interface {
+ SetAVTransportURI(InstanceID uint32, CurrentURI string, CurrentURIMetaData string) (err error)
+ Play(InstanceID uint32, Speed string) (err error)
+ Stop(InstanceID uint32) (err error)
+}
+
+func detectAVtransport(dev *goupnp.MaybeRootDevice) string {
+ transport := dev.Root.Device.FindService(av1.URN_AVTransport_1)
+ if len(transport) > 0 {
+ return av1.URN_AVTransport_1
+ }
+ transport = dev.Root.Device.FindService(av1.URN_AVTransport_2)
+ if len(transport) > 0 {
+ return av1.URN_AVTransport_2
+ }
+ return ""
+}
+
+func AVSetAndPlay(av avsetup) error {
+ urn := detectAVtransport(av.device)
+ var client avtransport
+
+ switch {
+ case urn == av1.URN_AVTransport_1:
+ clients, err := av1.NewAVTransport1ClientsByURL(av.device.Location)
+ if err != nil {
+ return err
+ }
+ client = avtransport(clients[0])
+ case urn == av1.URN_AVTransport_2:
+ clients, err := av1.NewAVTransport2ClientsByURL(av.device.Location)
+ if err != nil {
+ return err
+ }
+ client = avtransport(clients[0])
+ default:
+ return fmt.Errorf("no avtransport found")
+ }
+
+ var err error
+ try := func(metadata string) error {
+ err = client.SetAVTransportURI(0, av.streamURI, metadata)
+ if err != nil {
+ return fmt.Errorf("set uri: %v", err)
+ }
+ time.Sleep(time.Second)
+ err = client.Play(0, "1")
+ if err != nil {
+ return fmt.Errorf("play: %v", err)
+ }
+ return nil
+ }
+
+ metadata := fmt.Sprintf(
+ didlTemplate,
+ av.logoURI,
+ av.stream.mime,
+ av.stream.contentfeat,
+ av.stream.bitdepth,
+ av.stream.samplerate,
+ av.stream.channels,
+ av.streamURI,
+ )
+ metadata = strings.ReplaceAll(metadata, "\n", " ")
+ metadata = strings.ReplaceAll(metadata, "> <", "><")
+
+ err = try(metadata)
+ if err == nil {
+ return nil
+ }
+ log.Println(err)
+ log.Println("trying without metadata")
+ return try("")
+}
+
+func AVStop(device *goupnp.MaybeRootDevice) {
+ urn := detectAVtransport(device)
+ var client avtransport
+
+ switch {
+ case urn == av1.URN_AVTransport_1:
+ clients, err := av1.NewAVTransport1ClientsByURL(device.Location)
+ if err != nil {
+ return
+ }
+ client = avtransport(clients[0])
+ case urn == av1.URN_AVTransport_2:
+ clients, err := av1.NewAVTransport2ClientsByURL(device.Location)
+ if err != nil {
+ return
+ }
+ client = avtransport(clients[0])
+ default:
+ return
+ }
+
+ client.Stop(0)
+}
+
+const didlTemplate = `
+-
+object.item.audioItem.musicTrack
+Audio Cast
+sblast
+sblast
+%s
+%s
+
+`
diff --git a/dlna_content_features.go b/dlna_content_features.go
new file mode 100644
index 0000000..f3970fa
--- /dev/null
+++ b/dlna_content_features.go
@@ -0,0 +1,52 @@
+package main
+
+import "fmt"
+
+const DLNA_ORG_FLAG_SENDER_PACED = (1 << 31)
+const DLNA_ORG_FLAG_TIME_BASED_SEEK = (1 << 30)
+const DLNA_ORG_FLAG_BYTE_BASED_SEEK = (1 << 29)
+const DLNA_ORG_FLAG_PLAY_CONTAINER = (1 << 28)
+const DLNA_ORG_FLAG_S0_INCREASE = (1 << 27)
+const DLNA_ORG_FLAG_SN_INCREASE = (1 << 26)
+const DLNA_ORG_FLAG_RTSP_PAUSE = (1 << 25)
+const DLNA_ORG_FLAG_STREAMING_TRANSFER_MODE = (1 << 24)
+const DLNA_ORG_FLAG_INTERACTIVE_TRANSFERT_MODE = (1 << 23)
+const DLNA_ORG_FLAG_BACKGROUND_TRANSFERT_MODE = (1 << 22)
+const DLNA_ORG_FLAG_CONNECTION_STALL = (1 << 21)
+const DLNA_ORG_FLAG_DLNA_V15 = (1 << 20)
+const DLNA_ORG_FLAG_LINK_PROTECTED = (1 << 16)
+const DLNA_ORG_FLAG_CLEAR_TEXT_BYTE_SEEK_FULL = (1 << 15)
+const DLNA_ORG_FLAG_CLEAR_TEXT_BYTE_SEEK_LIMITED = (1 << 14)
+
+func formatDLNAFlags(flags int) string {
+ return fmt.Sprintf("DLNA.ORG_FLAGS=%.8x%.24x", flags, 0)
+}
+
+type dlnaContentFeatures struct {
+ profileName string
+ supportTimeSeek bool
+ supportRange bool
+ transcoded bool
+ flags int
+}
+
+func (c dlnaContentFeatures) String() (out string) {
+ if c.profileName != "" {
+ out += fmt.Sprintf("DLNA.ORG_PN=%s;", c.profileName)
+ }
+ if c.supportTimeSeek || c.supportRange {
+ out += fmt.Sprintf("DLNA.ORG_OP=%d%d;", bti(c.supportTimeSeek), bti(c.supportRange))
+ }
+ if c.transcoded {
+ out += fmt.Sprintf("DLNA.ORG_CI=%d;", bti(c.transcoded))
+ }
+ out += formatDLNAFlags(c.flags)
+ return
+}
+
+func bti(b bool) int {
+ if b {
+ return 1
+ }
+ return 0
+}
diff --git a/dlna_content_features_test.go b/dlna_content_features_test.go
new file mode 100644
index 0000000..8bb9bc8
--- /dev/null
+++ b/dlna_content_features_test.go
@@ -0,0 +1,23 @@
+package main
+
+import (
+ "testing"
+)
+
+func TestContentFeatures(t *testing.T) {
+ f := dlnaContentFeatures{
+ profileName: "MP3",
+ supportTimeSeek: true,
+ supportRange: false,
+ transcoded: true,
+ flags: DLNA_ORG_FLAG_STREAMING_TRANSFER_MODE |
+ DLNA_ORG_FLAG_BACKGROUND_TRANSFERT_MODE |
+ DLNA_ORG_FLAG_CONNECTION_STALL |
+ DLNA_ORG_FLAG_DLNA_V15,
+ }
+ want := "DLNA.ORG_PN=MP3;DLNA.ORG_OP=10;DLNA.ORG_CI=1;" +
+ "DLNA.ORG_FLAGS=01700000000000000000000000000000"
+ if f.String() != want {
+ t.Fatalf("got %s, wanted %s", f, want)
+ }
+}
diff --git a/dlna_device.go b/dlna_device.go
new file mode 100644
index 0000000..42b5419
--- /dev/null
+++ b/dlna_device.go
@@ -0,0 +1,76 @@
+// MIT+NoAI License
+//
+// # Copyright (c) 2024 Uģis Ģērmanis
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights///
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+//
+// This code may not be used to train artificial intelligence computer models
+// or retrieved by artificial intelligence software or hardware.
+package main
+
+import (
+ "fmt"
+
+ "github.com/huin/goupnp"
+ "github.com/huin/goupnp/dcps/av1"
+)
+
+func chooseUPNPDevice(lookup string) (*goupnp.MaybeRootDevice, error) {
+ if lookup == "" {
+ fmt.Println("Loading...")
+ }
+
+ roots, err := goupnp.DiscoverDevices(av1.URN_AVTransport_1)
+
+ if lookup == "" {
+ fmt.Print("\033[1A\033[K")
+ fmt.Println("----------")
+ }
+
+ if err != nil {
+ return nil, fmt.Errorf("discover: %v", err)
+ }
+ if lookup != "" {
+ for _, v := range roots {
+ if v.Root != nil {
+ if v.Root.Device.FriendlyName == lookup {
+ return &v, nil
+ }
+ }
+ }
+ return nil, fmt.Errorf("%s: not found", lookup)
+ }
+
+ if len(roots) == 0 {
+ return nil, fmt.Errorf("no dlna devices on the network found")
+ }
+ fmt.Println("DLNA receivers")
+
+ for i, v := range roots {
+ if v.Root != nil {
+ fmt.Printf("%d: %s\n", i, v.Root.Device.FriendlyName)
+ }
+ }
+
+ fmt.Println("----------")
+ fmt.Println("Select the DLNA device:")
+
+ selected := selector(roots)
+ return &roots[selected], nil
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..e0edc53
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,10 @@
+module sblast
+
+go 1.18
+
+require (
+ github.com/davecgh/go-spew v1.1.1
+ github.com/huin/goupnp v1.3.0
+)
+
+require golang.org/x/sync v0.6.0 // indirect
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..3d9cd19
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,7 @@
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
+github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
+golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
diff --git a/img.sblast.monitor.png b/img.sblast.monitor.png
new file mode 100644
index 0000000..acefe5f
Binary files /dev/null and b/img.sblast.monitor.png differ
diff --git a/lan_ip.go b/lan_ip.go
new file mode 100644
index 0000000..c8f3522
--- /dev/null
+++ b/lan_ip.go
@@ -0,0 +1,91 @@
+// MIT+NoAI License
+//
+// # Copyright (c) 2024 Uģis Ģērmanis
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights///
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+//
+// This code may not be used to train artificial intelligence computer models
+// or retrieved by artificial intelligence software or hardware.
+package main
+
+import (
+ "fmt"
+ "net"
+)
+
+func chooseStreamIP(lookup string) (net.IP, error) {
+ addrs, err := net.InterfaceAddrs()
+ if err != nil {
+ return nil, err
+ }
+
+ ips := make([]net.IP, 0)
+ for _, addr := range addrs {
+ if ipnet, ok := addr.(*net.IPNet); ok &&
+ !ipnet.IP.IsLoopback() &&
+ (ipnet.IP.To4() != nil || ipnet.IP.To16() != nil) {
+ ips = append(ips, ipnet.IP)
+ }
+ }
+
+ if len(ips) == 0 {
+ return nil, fmt.Errorf("no usable lan ip addresses found")
+ }
+ if lookup != "" {
+ lookupIp := net.ParseIP(lookup)
+ if lookupIp == nil {
+ return nil, fmt.Errorf("%s: not found", lookup)
+ }
+ for _, ip := range ips {
+ if ip.Equal(lookupIp) {
+ return lookupIp, nil
+ }
+ }
+ return nil, fmt.Errorf("%s: not found", lookup)
+ }
+ fmt.Println("Your LAN ip addresses")
+ for i, ip := range ips {
+ fmt.Printf("%d: %s\n", i, ip)
+ }
+
+ fmt.Println("----------")
+ fmt.Println("Select the lan IP address for the stream:")
+
+ selected := selector(ips)
+ return ips[selected], nil
+}
+
+func findInterface(ip net.IP) (string, error) {
+ infs, err := net.Interfaces()
+ if err != nil {
+ return "", err
+ }
+ for _, inf := range infs {
+ addrs, err := inf.Addrs()
+ if err != nil {
+ continue
+ }
+ for _, addr := range addrs {
+ if addr.(*net.IPNet).IP.Equal(ip) {
+ return inf.Name, nil
+ }
+ }
+ }
+ return "", fmt.Errorf("no interface found for ip")
+}
diff --git a/logo.png b/logo.png
new file mode 100644
index 0000000..0cb2eaa
Binary files /dev/null and b/logo.png differ
diff --git a/logo_serve.go b/logo_serve.go
new file mode 100644
index 0000000..fb0066b
--- /dev/null
+++ b/logo_serve.go
@@ -0,0 +1,22 @@
+package main
+
+import (
+ "fmt"
+ "net/http"
+)
+
+type logo []byte
+
+func (l logo) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "image/png")
+ w.Header().Set("Content-Length", fmt.Sprint(len(l)))
+ if r.Method == http.MethodHead {
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ if r.Method != http.MethodGet {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ return
+ }
+ w.Write(l)
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..7b8c7e5
--- /dev/null
+++ b/main.go
@@ -0,0 +1,342 @@
+// MIT+NoAI License
+//
+// # Copyright (c) 2024 Uģis Ģērmanis
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights///
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+//
+// This code may not be used to train artificial intelligence computer models
+// or retrieved by artificial intelligence software or hardware.
+package main
+
+import (
+ "bytes"
+ _ "embed"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "os/signal"
+ "strings"
+ "syscall"
+
+ "github.com/davecgh/go-spew/spew"
+ "github.com/huin/goupnp"
+ "github.com/huin/goupnp/dcps/av1"
+)
+
+const (
+ sblastMONITOR = "sblast.monitor"
+ LOGO_PATH = "logo.png"
+ VERSION = "v0.7.0"
+)
+
+//go:embed logo.png
+var logobytes []byte
+
+var logsblast = new(bool)
+
+func main() {
+ // check for dependencies
+ exes := []string{
+ "pactl",
+ "parec",
+ "ffmpeg",
+ }
+ for _, exe := range exes {
+ if _, err := exec.LookPath(exe); err != nil {
+ fmt.Fprintln(os.Stderr, "dependency:", err)
+ os.Exit(1)
+ }
+ }
+ device := flag.String("device", "", "dlna device's friendly name")
+ source := flag.String("source", "", "audio source (pactl list sources short | cut -f2)")
+ ip := flag.String("ip", "", "host ip address")
+ port := flag.Int("port", 9000, "stream port")
+ chunk := flag.Int("chunk", 1, "chunk size in seconds")
+ bitrate := flag.Int("bitrate", 320, "audio format bitrate")
+ format := flag.String("format", "mp3", "stream audio format")
+ mime := flag.String("mime", "audio/mpeg", "stream mime type")
+ useaac := flag.Bool("useaac", false, "use aac audio")
+ useflac := flag.Bool("useflac", false, "use flac audio")
+ uselpcm := flag.Bool("uselpcm", false, "use lpcm audio")
+ uselpcmle := flag.Bool("uselpcmle", false, "use lpcm little-endian audio")
+ usewav := flag.Bool("usewav", false, "use wav audio")
+ bits := flag.Int("bits", 16, "audio bitdepth")
+ rate := flag.Int("rate", 44100, "audio sample rate")
+ channels := flag.Int("channels", 2, "audio channels")
+ dummy := flag.Bool("dummy", false, "only serve content")
+ debug := flag.Bool("debug", false, "print debug info")
+ headers := flag.Bool("headers", false, "print request headers")
+ logsblast = flag.Bool("log", false, "log parec and ffmpeg stderr")
+ nochunked := flag.Bool("nochunked", false, "disable chunked tranfer endcoding")
+ version := flag.Bool("version", false, "show sblast version")
+
+ flag.Parse()
+
+ if *version {
+ fmt.Fprintln(os.Stderr, VERSION)
+ os.Exit(0)
+ }
+
+ var (
+ sblastSinkID []byte
+ isPlaying bool
+ DLNADevice *goupnp.MaybeRootDevice
+ err error
+ )
+
+ // trap ctrl+c and kill and terminal hang up
+ sig := make(chan os.Signal, 1)
+ signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
+
+ cleanup := func() {
+ if sblastSinkID != nil {
+ log.Println("unloading the sblast sink")
+ exec.Command("pactl", "unload-module", string(sblastSinkID)).Run()
+ }
+ }
+
+ go func() {
+ <-sig
+ fmt.Println()
+ cleanup()
+ if isPlaying && !*dummy {
+ log.Println("stopping avtransport and exiting")
+ AVStop(DLNADevice)
+ }
+ fmt.Println("terminated...")
+ os.Exit(0)
+ }()
+ if !*dummy {
+ DLNADevice, err = chooseUPNPDevice(*device)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "upnp:", err)
+ os.Exit(1)
+ }
+ }
+
+ if *debug {
+ spew.Fdump(os.Stderr, DLNADevice)
+ var location string
+ urn := detectAVtransport(DLNADevice)
+ switch {
+ case urn == av1.URN_AVTransport_1:
+ clients, err := av1.NewAVTransport1ClientsByURL(DLNADevice.Location)
+ if err == nil {
+ location = clients[0].Location.String()
+ }
+ spew.Fdump(os.Stderr, clients, err)
+
+ case urn == av1.URN_AVTransport_2:
+ clients, err := av1.NewAVTransport2ClientsByURL(DLNADevice.Location)
+ if err == nil {
+ location = clients[0].Location.String()
+ }
+ spew.Fdump(os.Stderr, clients, err)
+ }
+
+ get := func() {
+ if location == "" {
+ return
+ }
+ resp, err := http.Get(location)
+ if err != nil {
+ return
+ }
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return
+ }
+ spew.Fprintln(os.Stderr, string(data))
+ }
+ get()
+
+ if !*headers {
+ os.Exit(0)
+ }
+ }
+ if *device == "" {
+ fmt.Println("----------")
+ }
+
+ sink, err := chooseAudioSource(*source)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "audio:", err)
+ os.Exit(1)
+ }
+ // on-demand handling of sblast sink
+ if sink == sblastMONITOR {
+ sblastSink := exec.Command(
+ "pactl", "load-module", "module-null-sink", "sink_name=sblast",
+ )
+ var err error
+ sblastSinkID, err = sblastSink.Output()
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "sblast sink:", err)
+ os.Exit(1)
+ }
+ sblastSinkID = bytes.TrimSpace(sblastSinkID)
+ }
+
+ if *source == "" {
+ fmt.Println("----------")
+ }
+ streamHost, err := chooseStreamIP(*ip)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "network:", err)
+ cleanup()
+ os.Exit(1)
+ }
+ if *ip == "" {
+ fmt.Println("----------")
+ }
+
+ log.Printf(
+ "starting the stream on port %d "+
+ "(configure your firewall if necessary)",
+ *port,
+ )
+ streamHandler := stream{
+ sink: sink,
+ mime: *mime,
+ format: *format,
+ bitrate: *bitrate,
+ chunk: *chunk,
+ printheaders: *headers,
+ bitdepth: *bits,
+ samplerate: *rate,
+ channels: *channels,
+ nochunked: *nochunked,
+ }
+
+ switch {
+ case *useaac:
+ streamHandler.format = "adts"
+ streamHandler.mime = "audio/aac"
+ case *useflac:
+ streamHandler.format = "flac"
+ streamHandler.mime = "audio/flac"
+ streamHandler.bitrate = 0
+ case *uselpcm:
+ streamHandler.format = "lpcm"
+ streamHandler.mime = fmt.Sprintf("audio/L%d;rate=%d;channels=%d", *bits, *rate, *channels)
+ streamHandler.bitrate = 0
+ streamHandler.be = true
+ case *uselpcmle:
+ streamHandler.format = "lpcm"
+ streamHandler.mime = fmt.Sprintf("audio/L%d;rate=%d;channels=%d", *bits, *rate, *channels)
+ streamHandler.bitrate = 0
+ case *usewav:
+ streamHandler.format = "wav"
+ streamHandler.mime = "audio/wav"
+ streamHandler.bitrate = 0
+ }
+
+ streamHandler.contentfeat = dlnaContentFeatures{
+ profileName: strings.ToUpper(streamHandler.format),
+ supportTimeSeek: true,
+ supportRange: false,
+ flags: DLNA_ORG_FLAG_DLNA_V15 |
+ DLNA_ORG_FLAG_CONNECTION_STALL |
+ DLNA_ORG_FLAG_STREAMING_TRANSFER_MODE |
+ DLNA_ORG_FLAG_BACKGROUND_TRANSFERT_MODE,
+ }
+
+ streamPath := "stream." + strings.ToLower(streamHandler.format)
+
+ mux := http.NewServeMux()
+ mux.Handle("/"+streamPath, streamHandler)
+ var logoHandler logo = logobytes
+ mux.Handle("/"+LOGO_PATH, logoHandler)
+ httpServer := &http.Server{
+ Addr: fmt.Sprintf(":%d", *port),
+ ReadTimeout: -1,
+ WriteTimeout: -1,
+ Handler: mux,
+ }
+ go func() {
+ err := httpServer.ListenAndServe()
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "server:", err)
+ cleanup()
+ os.Exit(1)
+ }
+ }()
+ // detect when the stream server is up
+ for {
+ _, err := net.Dial("tcp", fmt.Sprintf(":%d", *port))
+ if err == nil {
+ break
+ }
+ }
+
+ var (
+ streamURI string
+ logoURI string
+ protocol = "http"
+ )
+
+ if !*dummy && *format == "mp3" && detectSonos(DLNADevice) {
+ protocol = "x-rincon-mp3radio"
+ }
+
+ if streamHost.To4() != nil {
+ streamURI = fmt.Sprintf("%s://%s:%d/%s",
+ protocol, streamHost, *port, streamPath)
+ logoURI = fmt.Sprintf("http://%s:%d/%s",
+ streamHost, *port, LOGO_PATH)
+ } else {
+ var zone string
+ if streamHost.IsLinkLocalUnicast() {
+ ifname, err := findInterface(streamHost)
+ if err == nil {
+ zone = "%" + ifname
+ }
+ }
+ streamURI = fmt.Sprintf("%s://[%s%s]:%d/%s",
+ protocol, streamHost, zone, *port, streamPath)
+ logoURI = fmt.Sprintf("http://[%s%s]:%d/%s",
+ streamHost, zone, *port, LOGO_PATH)
+ }
+
+ log.Printf("stream URI: %s\n", streamURI)
+
+ log.Println("setting avtransport URI and playing")
+ if !*dummy {
+ av := avsetup{
+ device: DLNADevice,
+ stream: streamHandler,
+ logoURI: logoURI,
+ streamURI: streamURI,
+ }
+ err = AVSetAndPlay(av)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "transport:", err)
+ cleanup()
+ os.Exit(1)
+ }
+ }
+
+ isPlaying = true
+ select {}
+}
diff --git a/makerel/main.go b/makerel/main.go
new file mode 100644
index 0000000..daf888e
--- /dev/null
+++ b/makerel/main.go
@@ -0,0 +1,88 @@
+// MIT+NoAI License
+//
+// # Copyright (c) 2024 Uģis Ģērmanis
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights///
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+//
+// This code may not be used to train artificial intelligence computer models
+// or retrieved by artificial intelligence software or hardware.
+package main
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+)
+
+func main() {
+ targets := []struct {
+ os string
+ arch []string
+ }{
+ {"linux",
+ []string{
+ "386",
+ "amd64",
+ "arm",
+ "arm64",
+ "loong64",
+ "mips",
+ "mips64",
+ "mips64le",
+ "mipsle",
+ "ppc64",
+ "ppc64le",
+ "riscv64",
+ "s390x",
+ },
+ },
+ {
+ "android",
+ []string{"arm64"},
+ },
+ }
+
+ for _, t := range targets {
+ for _, arch := range t.arch {
+ build := exec.Command("go", "build")
+ build.Stderr = os.Stderr
+ build.Stdout = os.Stdout
+ build.Env = append(os.Environ(), "GOOS="+t.os, "GOARCH="+arch)
+ if err := build.Run(); err != nil {
+ panic(err)
+ }
+ zip := exec.Command("zip", "")
+ zip.Stderr = os.Stderr
+ zip.Stdout = os.Stdout
+ zip.Args = []string{
+ "-1",
+ fmt.Sprintf("sblast_%s_%s.zip", t.os, arch),
+ "sblast",
+ "LICENSE",
+ "README.md",
+ }
+ if err := zip.Run(); err != nil {
+ panic(err)
+ }
+ if err := os.Remove("sblast"); err != nil {
+ panic(err)
+ }
+ }
+ }
+}
diff --git a/selector.go b/selector.go
new file mode 100644
index 0000000..f133f7d
--- /dev/null
+++ b/selector.go
@@ -0,0 +1,52 @@
+// MIT+NoAI License
+//
+// # Copyright (c) 2024 Uģis Ģērmanis
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights///
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+//
+// This code may not be used to train artificial intelligence computer models
+// or retrieved by artificial intelligence software or hardware.
+package main
+
+import (
+ "fmt"
+ "os"
+ "strconv"
+)
+
+func selector[slice any](s []slice) int {
+ var choice int
+ for {
+ var choiceStr string
+ _, err := fmt.Fscanln(os.Stdin, &choiceStr)
+ if err != nil {
+ fmt.Print("\033[1A\033[K")
+ continue
+ }
+ choice, err = strconv.Atoi(choiceStr)
+ if err != nil || choice >= len(s) {
+ fmt.Print("\033[1A\033[K")
+ } else {
+ break
+ }
+ }
+ fmt.Print("\033[1A\033[K")
+ fmt.Printf("[%d]\n", choice)
+ return choice
+}
diff --git a/sonos.go b/sonos.go
new file mode 100644
index 0000000..cdcb8c2
--- /dev/null
+++ b/sonos.go
@@ -0,0 +1,69 @@
+// MIT+NoAI License
+//
+// Copyright (c) 2024 Uģis Ģērmanis
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights///
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+//
+// This code may not be used to train artificial intelligence computer models
+// or retrieved by artificial intelligence software or hardware.
+
+package main
+
+import (
+ "encoding/xml"
+ "io"
+ "net/http"
+ "strings"
+
+ "github.com/huin/goupnp"
+ "github.com/huin/goupnp/dcps/av1"
+)
+
+func detectSonos(dev *goupnp.MaybeRootDevice) bool {
+ var xmldata struct {
+ Device struct {
+ Manufacturer string `xml:"manufacturer"`
+ } `xml:"device"`
+ }
+
+ clients, err := av1.NewAVTransport1ClientsByURL(dev.Location)
+ if err != nil {
+ return false
+ }
+ for _, client := range clients {
+ resp, err := http.Get(client.Location.String())
+ if err != nil {
+ return false
+ }
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return false
+ }
+ err = xml.Unmarshal(data, &xmldata)
+ if err != nil {
+ return false
+ }
+ man := xmldata.Device.Manufacturer
+ man = strings.ToLower(man)
+ if strings.Contains(man, "sonos") {
+ return true
+ }
+ }
+ return false
+}