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 + +sblast logo + +## 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 + +sblast.monitor example + +* 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 +}