├── .gitignore ├── src ├── server │ ├── static │ │ └── favicon │ │ │ ├── favicon.ico │ │ │ ├── favicomatic.zip │ │ │ ├── favicon-128.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon-96x96.png │ │ │ ├── mstile-70x70.png │ │ │ ├── favicon-196x196.png │ │ │ ├── mstile-144x144.png │ │ │ ├── mstile-150x150.png │ │ │ ├── mstile-310x150.png │ │ │ ├── mstile-310x310.png │ │ │ ├── apple-touch-icon-57x57.png │ │ │ ├── apple-touch-icon-60x60.png │ │ │ ├── apple-touch-icon-72x72.png │ │ │ ├── apple-touch-icon-76x76.png │ │ │ ├── apple-touch-icon-114x114.png │ │ │ ├── apple-touch-icon-120x120.png │ │ │ ├── apple-touch-icon-144x144.png │ │ │ ├── apple-touch-icon-152x152.png │ │ │ └── code.txt │ ├── template │ │ ├── live.html │ │ ├── archive.html │ │ ├── main.html │ │ ├── chat.html │ │ ├── download.html │ │ ├── sya.html │ │ └── template.html │ └── server.go ├── ffmpeg │ ├── ffmpeg_linux.go │ ├── ffmpeg_windows.go │ └── ffmpeg_darwin.go ├── filecreated │ ├── filecreated_darwin.go │ ├── filecreated_windows.go │ └── filecreated_linux.go ├── utils │ └── utils.go ├── clearscreen │ └── clearscreen.go ├── chat │ └── chat.go └── client │ └── client.go ├── go.mod ├── Makefile ├── main.go ├── README.md ├── go.sum └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | ffmpeg* 2 | *.exe 3 | streammyaudio* 4 | tmp* 5 | archived -------------------------------------------------------------------------------- /src/server/static/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schollz/streammyaudio/HEAD/src/server/static/favicon/favicon.ico -------------------------------------------------------------------------------- /src/server/static/favicon/favicomatic.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schollz/streammyaudio/HEAD/src/server/static/favicon/favicomatic.zip -------------------------------------------------------------------------------- /src/server/static/favicon/favicon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schollz/streammyaudio/HEAD/src/server/static/favicon/favicon-128.png -------------------------------------------------------------------------------- /src/server/static/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schollz/streammyaudio/HEAD/src/server/static/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /src/server/static/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schollz/streammyaudio/HEAD/src/server/static/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /src/server/static/favicon/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schollz/streammyaudio/HEAD/src/server/static/favicon/favicon-96x96.png -------------------------------------------------------------------------------- /src/server/static/favicon/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schollz/streammyaudio/HEAD/src/server/static/favicon/mstile-70x70.png -------------------------------------------------------------------------------- /src/server/static/favicon/favicon-196x196.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schollz/streammyaudio/HEAD/src/server/static/favicon/favicon-196x196.png -------------------------------------------------------------------------------- /src/server/static/favicon/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schollz/streammyaudio/HEAD/src/server/static/favicon/mstile-144x144.png -------------------------------------------------------------------------------- /src/server/static/favicon/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schollz/streammyaudio/HEAD/src/server/static/favicon/mstile-150x150.png -------------------------------------------------------------------------------- /src/server/static/favicon/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schollz/streammyaudio/HEAD/src/server/static/favicon/mstile-310x150.png -------------------------------------------------------------------------------- /src/server/static/favicon/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schollz/streammyaudio/HEAD/src/server/static/favicon/mstile-310x310.png -------------------------------------------------------------------------------- /src/ffmpeg/ffmpeg_linux.go: -------------------------------------------------------------------------------- 1 | package ffmpeg 2 | 3 | func Binary() string { 4 | return "ffmpeg" 5 | } 6 | 7 | func Clean() { 8 | 9 | } 10 | -------------------------------------------------------------------------------- /src/server/static/favicon/apple-touch-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schollz/streammyaudio/HEAD/src/server/static/favicon/apple-touch-icon-57x57.png -------------------------------------------------------------------------------- /src/server/static/favicon/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schollz/streammyaudio/HEAD/src/server/static/favicon/apple-touch-icon-60x60.png -------------------------------------------------------------------------------- /src/server/static/favicon/apple-touch-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schollz/streammyaudio/HEAD/src/server/static/favicon/apple-touch-icon-72x72.png -------------------------------------------------------------------------------- /src/server/static/favicon/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schollz/streammyaudio/HEAD/src/server/static/favicon/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /src/server/static/favicon/apple-touch-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schollz/streammyaudio/HEAD/src/server/static/favicon/apple-touch-icon-114x114.png -------------------------------------------------------------------------------- /src/server/static/favicon/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schollz/streammyaudio/HEAD/src/server/static/favicon/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /src/server/static/favicon/apple-touch-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schollz/streammyaudio/HEAD/src/server/static/favicon/apple-touch-icon-144x144.png -------------------------------------------------------------------------------- /src/server/static/favicon/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schollz/streammyaudio/HEAD/src/server/static/favicon/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /src/filecreated/filecreated_darwin.go: -------------------------------------------------------------------------------- 1 | package filecreated 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | func FileCreated(fname string) time.Time { 8 | return time.Now() 9 | } 10 | -------------------------------------------------------------------------------- /src/filecreated/filecreated_windows.go: -------------------------------------------------------------------------------- 1 | package filecreated 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | func FileCreated(fname string) time.Time { 8 | return time.Now() 9 | } 10 | -------------------------------------------------------------------------------- /src/filecreated/filecreated_linux.go: -------------------------------------------------------------------------------- 1 | package filecreated 2 | 3 | import ( 4 | "os" 5 | "syscall" 6 | "time" 7 | ) 8 | 9 | func FileCreated(fname string) time.Time { 10 | finfo, _ := os.Stat(fname) 11 | stat_t := finfo.Sys().(*syscall.Stat_t) 12 | return timespecToTime(stat_t.Mtim) 13 | } 14 | 15 | func timespecToTime(ts syscall.Timespec) time.Time { 16 | return time.Unix(int64(ts.Sec), int64(ts.Nsec)) 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "strings" 4 | 5 | // GetStringInBetween returns empty string if no start or end string found 6 | func GetStringInBetween(str string, start string, end string) (result string) { 7 | s := strings.Index(str, start) 8 | if s == -1 { 9 | return 10 | } 11 | s += len(start) 12 | e := strings.Index(str[s:], end) 13 | if e == -1 { 14 | return 15 | } 16 | return str[s : s+e] 17 | } 18 | -------------------------------------------------------------------------------- /src/ffmpeg/ffmpeg_windows.go: -------------------------------------------------------------------------------- 1 | package ffmpeg 2 | 3 | import ( 4 | _ "embed" 5 | "io/ioutil" 6 | "os" 7 | ) 8 | 9 | //go:embed ffmpeg.exe 10 | var b []byte 11 | 12 | var loaded bool 13 | 14 | func Binary() string { 15 | if !loaded { 16 | loaded = true 17 | go func() { 18 | ioutil.WriteFile("./ffmpeg.exe", b, 0777) 19 | }() 20 | } 21 | return "./ffmpeg.exe" 22 | } 23 | 24 | func Clean() { 25 | os.Remove("./ffmpeg.exe") 26 | } 27 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/schollz/streammyaudio 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/dchest/captcha v1.0.0 7 | github.com/gabriel-vasile/mimetype v1.4.6 8 | github.com/gorilla/websocket v1.5.3 9 | github.com/h2non/filetype v1.1.3 10 | github.com/manifoldco/promptui v0.9.0 11 | github.com/schollz/logger v1.2.0 12 | ) 13 | 14 | require ( 15 | github.com/chzyer/readline v1.5.1 // indirect 16 | golang.org/x/net v0.30.0 // indirect 17 | golang.org/x/sys v0.26.0 // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /src/ffmpeg/ffmpeg_darwin.go: -------------------------------------------------------------------------------- 1 | package ffmpeg 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | log "github.com/schollz/logger" 7 | "io/ioutil" 8 | "os" 9 | ) 10 | 11 | //go:embed ffmpegmac 12 | var b []byte 13 | 14 | var loadedDarwin bool 15 | 16 | func init() { 17 | fmt.Println("loaded ffmpeg ", len(b)) 18 | } 19 | 20 | func Binary() string { 21 | log.Debugf("loaded ffmpeg: %d", len(b)) 22 | if !loadedDarwin { 23 | loadedDarwin = true 24 | go func() { 25 | ioutil.WriteFile("./ffmpeg", b, 0777) 26 | }() 27 | } 28 | return "./ffmpeg" 29 | } 30 | 31 | func Clean() { 32 | os.Remove("./ffmpeg") 33 | } 34 | -------------------------------------------------------------------------------- /src/server/template/live.html: -------------------------------------------------------------------------------- 1 | {{ define "live" }} 2 | {{ template "prebody" . }} 3 |
4 | If you want your stream to appear here, select "advertise" when choosing the settings. Click on any of the links below to go to a chatroom for that particular stream. 5 |
6 | {{ if .Items}} 7 |4 | If you want your stream to appear here, select "archive" when choosing the settings. 5 |
6 |Remove an archive by clicking the ❌ . Rename an archive by clicking ✎ . There are no logins or passwords so anyone can remove/edit anything. Be respectful.
7 | {{if .Archived}} 8 |
13 |
20 |
24 |
32 | 9 | What is this? this software makes it easy to livestream your audio from any computer using a simple programs you just double click. That's it. Make a live podcast, stream your piano practice, whatever you'd like. 10 |
11 |12 | What isn't this? It's not twitch, it's not YouTube. There is no login, no usernames, no passwords, no costs. There is no branding, no engagement. 13 |
14 |15 | No logins? No logins. Your steam is given a name which you can share with others to access it. You can also"advertise" you're steam so it will be featured on the "live" page. 16 |
17 |18 | It's free? It is free. This is a simple website that costs me very little too run. Is yours like to donate to this site here's my Bitcoin address 19 |
20 |Its easy. Simply download the release for your system and double click on it! It will do the rest. If you don't want to download, you can compile it yourself from source.
25 |Is it secure? No - all the broadcasts will be going through this public server. If you'd like, you can easily run your own server, since everything is open source.
26 |click ❌ to remove an archive, ✎ to rename an archive (maybe don't remove/rename ones that you didn't create).
40 | {{range .Archived}}{{ .Filename }} ({{.Created.Format "Jan 02, 2006 15:04:05 UTC"}}, 41 |5 | Its easy to install the software - just download the program, unzip it, and double-click. If you don't want to download a release, you can always build the program yourself from source. 6 |
7 |
13 | You don't have to download anything to use this website. As long as you have curl and either ffmpeg or vlc then you can stream your audio to this website.
14 |
16 | Create station using ffmpeg with a simple line of code.
17 |
> # linux
19 | > ffmpeg -f alsa -i hw:0 -f mp3 - | \
20 | curl -s -k -H "Transfer-Encoding: chunked" -X POST -T - \
21 | "https://streammyaudio.com/YOURSTATIONNAME.mp3?stream=true&advertise=true"
22 |
23 | Create station using vlc with a simple line of code.
24 |
> # linux
26 | > vlc -I dummy alsa://plughw:0,0 --sout='#transcode{vcodec=none,acodec=mp3,ab=256,channels=2,samplerate=44100,scodec=none}:standard{access=file,mux=mp3,dst=-}' --no-sout-all --sout-keep | \
27 | curl -k -H "Transfer-Encoding: chunked" -X POST -T - 'https://streammyaudio.com/YOURSTATIONNAME.mp3?stream=true&advertise=true'
28 | > # mac os
29 | > vlc -I dummy -vvv qtsound:// --sout='#transcode{vcodec=none,acodec=mp3,ab=256,channels=2,samplerate=44100,scodec=none}:standard{access=file,mux=mp3,dst=-}' --no-sout-all --sout-keep | \
30 | curl -k -H "Transfer-Encoding: chunked" -X POST -T - 'https://streammyaudio.com/YOURSTATIONNAME.mp3?stream=true&advertise=true'
31 | 39 | What is this? sma makes it easy to livestream your audio from any computer using a simple programs you just double click. That's it. Make a live podcast, stream your piano practice, whatever you'd like. 40 |
41 |49 | What is this? sma makes it easy to livestream your audio from any computer using a simple programs you just double click. That's it. Make a live podcast, stream your piano practice, whatever you'd like. 50 |
51 |59 | Its easy to build from source. First download [Go](https://go.dev/dl/) for your operating system. Then you can clone and build the repostiory. 60 |
61 |
62 | > git clone https://github.com/schollz/streammyaudio
63 | > cd streammyaudio
64 | > go build -v
65 |
66 | Make sure you also have ffmpeg installed.
68 | > sudo apt install ffmpeg
69 |
70 | 73 | If you have any problems with the download please feel free to create an issue on Github. 74 |
75 | {{ template "postbody" . }} 76 | {{end}} -------------------------------------------------------------------------------- /src/chat/chat.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/gorilla/websocket" 9 | log "github.com/schollz/logger" 10 | ) 11 | 12 | const ( 13 | // Time allowed to write a message to the peer. 14 | writeWait = 10 * time.Second 15 | 16 | // Time allowed to read the next pong message from the peer. 17 | pongWait = 60 * time.Second 18 | 19 | // Send pings to peer with this period. Must be less than pongWait. 20 | pingPeriod = (pongWait * 9) / 10 21 | 22 | // Maximum message size allowed from peer. 23 | maxMessageSize = 512 24 | ) 25 | 26 | var upgrader = websocket.Upgrader{ 27 | ReadBufferSize: 1024, 28 | WriteBufferSize: 1024, 29 | } 30 | 31 | // connection is an middleman between the websocket connection and the hub. 32 | type connection struct { 33 | // The websocket connection. 34 | ws *websocket.Conn 35 | 36 | // Buffered channel of outbound messages. 37 | send chan []byte 38 | } 39 | 40 | // readPump pumps messages from the websocket connection to the hub. 41 | func (s subscription) readPump() { 42 | c := s.conn 43 | defer func() { 44 | h.unregister <- s 45 | c.ws.Close() 46 | }() 47 | c.ws.SetReadLimit(maxMessageSize) 48 | c.ws.SetReadDeadline(time.Now().Add(pongWait)) 49 | c.ws.SetPongHandler(func(string) error { c.ws.SetReadDeadline(time.Now().Add(pongWait)); return nil }) 50 | for { 51 | _, msg, err := c.ws.ReadMessage() 52 | if err != nil { 53 | if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway) { 54 | log.Errorf("error: %v", err) 55 | } 56 | break 57 | } 58 | m := message{msg, s.room} 59 | h.broadcast <- m 60 | } 61 | } 62 | 63 | // write writes a message with the given message type and payload. 64 | func (c *connection) write(mt int, payload []byte) error { 65 | c.ws.SetWriteDeadline(time.Now().Add(writeWait)) 66 | return c.ws.WriteMessage(mt, payload) 67 | } 68 | 69 | // writePump pumps messages from the hub to the websocket connection. 70 | func (s *subscription) writePump() { 71 | c := s.conn 72 | ticker := time.NewTicker(pingPeriod) 73 | defer func() { 74 | ticker.Stop() 75 | c.ws.Close() 76 | }() 77 | for { 78 | select { 79 | case message, ok := <-c.send: 80 | if !ok { 81 | c.write(websocket.CloseMessage, []byte{}) 82 | return 83 | } 84 | if err := c.write(websocket.TextMessage, message); err != nil { 85 | return 86 | } 87 | case <-ticker.C: 88 | if err := c.write(websocket.PingMessage, []byte{}); err != nil { 89 | return 90 | } 91 | } 92 | } 93 | } 94 | 95 | // serveWs handles websocket requests from the peer. 96 | func Serve(w http.ResponseWriter, r *http.Request) { 97 | ws, err := upgrader.Upgrade(w, r, nil) 98 | vals := r.URL.Query() // Returns a url.Values, which is a map[string][]string 99 | room, ok := vals["room"] // Note type, not ID. ID wasn't specified anywhere. 100 | if !ok { 101 | err = fmt.Errorf("no room specified") 102 | return 103 | } 104 | log.Debugf("entered '%s'", room[0]) 105 | if err != nil { 106 | log.Error(err) 107 | return 108 | } 109 | c := &connection{send: make(chan []byte, 256), ws: ws} 110 | s := subscription{c, room[0]} 111 | h.register <- s 112 | go s.writePump() 113 | s.readPump() 114 | } 115 | 116 | /////////////////////// 117 | // hub 118 | /////////////////////// 119 | type message struct { 120 | data []byte 121 | room string 122 | } 123 | 124 | type subscription struct { 125 | conn *connection 126 | room string 127 | } 128 | 129 | // hub maintains the set of active connections and broadcasts messages to the 130 | // connections. 131 | type hub struct { 132 | // Registered connections. 133 | rooms map[string]map[*connection]bool 134 | 135 | // Inbound messages from the connections. 136 | broadcast chan message 137 | 138 | // Register requests from the connections. 139 | register chan subscription 140 | 141 | // Unregister requests from connections. 142 | unregister chan subscription 143 | } 144 | 145 | var h = hub{ 146 | broadcast: make(chan message), 147 | register: make(chan subscription), 148 | unregister: make(chan subscription), 149 | rooms: make(map[string]map[*connection]bool), 150 | } 151 | 152 | func Run() { 153 | h.Run() 154 | } 155 | 156 | func (h *hub) Run() { 157 | for { 158 | select { 159 | case s := <-h.register: 160 | connections := h.rooms[s.room] 161 | if connections == nil { 162 | connections = make(map[*connection]bool) 163 | h.rooms[s.room] = connections 164 | } 165 | h.rooms[s.room][s.conn] = true 166 | case s := <-h.unregister: 167 | connections := h.rooms[s.room] 168 | if connections != nil { 169 | if _, ok := connections[s.conn]; ok { 170 | delete(connections, s.conn) 171 | close(s.conn.send) 172 | if len(connections) == 0 { 173 | delete(h.rooms, s.room) 174 | } 175 | } 176 | } 177 | case m := <-h.broadcast: 178 | log.Debugf("data: %+v", m) 179 | connections := h.rooms[m.room] 180 | for c := range connections { 181 | select { 182 | case c.send <- m.data: 183 | default: 184 | close(c.send) 185 | delete(connections, c) 186 | if len(connections) == 0 { 187 | delete(h.rooms, m.room) 188 | } 189 | } 190 | } 191 | } 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/server/template/sya.html: -------------------------------------------------------------------------------- 1 | {{ define "sya" }} 2 | {{ template "prebody" . }} 3 |4 | What is this? "Stream my audio" is a easy and free way to stream your audio in high fidelity. This software lets you livestream audio from your computer to this website as easily as possible. Use it to play demos for people you know, or make a live podcast, or stream your piano practice, or whatever you'd like. 5 |
6 |7 | What isn't this? It's not social media. Not Twitch, not YouTube, not "Meta". There is no login, no usernames, no passwords, no authentication, no costs, no branding. 8 |
9 |10 | No logins? No logins. You give your stream a name which serves as the link for others to listen. By default the stream names are not shared, but there is an option to "advertise" the station. By advertising, the station name will be featured on the live page. 11 |
12 |13 | It's free? It is free. This is a simple website that costs me very little too run. If you find this site useful, consider sponsoring me. 14 |
15 | How do I start? Start by by downloading a client or using common command-line tools to send audio. 16 | 17 |19 | You can get started by using common command-line tools or downloading a program that makes it easy to just double-click and go. If you don't want to download a release, you can always build the program yourself from source. 20 |
21 |
27 | You don't have to download anything to use this website. As long as you have curl and either ffmpeg or vlc then you can stream your audio to this website.
28 |
30 | Create station using ffmpeg with a simple line of code.
31 |
> # linux
33 | > ffmpeg -f alsa -i hw:0 -f mp3 - | \
34 | curl -s -k -H "Transfer-Encoding: chunked" -X POST -T - \
35 | "https://streammyaudio.com/YOURSTATIONNAME.mp3?stream=true&advertise=true"
36 | > # mac os
37 | > ffmpeg -f avfoundation -i ":default" -f mp3 - | \
38 | curl -s -k -H "Transfer-Encoding: chunked" -X POST -T - \
39 | "https://streammyaudio.com/YOURSTATIONNAME.mp3?stream=true&advertise=true"
40 |
41 | Create station using vlc with a simple line of code.
42 |
> # linux
44 | > vlc -I dummy alsa://plughw:0,0 --sout='#transcode{vcodec=none,acodec=mp3,ab=256,channels=2,samplerate=44100,scodec=none}:standard{access=file,mux=mp3,dst=-}' --no-sout-all --sout-keep | \
45 | curl -k -H "Transfer-Encoding: chunked" -X POST -T - 'https://streammyaudio.com/YOURSTATIONNAME.mp3?stream=true&advertise=true'
46 | > # mac os
47 | > vlc -I dummy -vvv qtsound:// --sout='#transcode{vcodec=none,acodec=mp3,ab=256,channels=2,samplerate=44100,scodec=none}:standard{access=file,mux=mp3,dst=-}' --no-sout-all --sout-keep | \
48 | curl -k -H "Transfer-Encoding: chunked" -X POST -T - 'https://streammyaudio.com/YOURSTATIONNAME.mp3?stream=true&advertise=true'
49 |
50 |
51 | Create station from
52 | yt-dlp
53 | playlist
54 |
55 | with a few simple lines of code. (Thanks to @SuperSonicHub1!)
56 |
#!/bin/bash
58 | # USAGE: stream-playlist playlist-url station-name
59 |
60 | yt-dlp-to-ffconcat() {
61 | local url=$1
62 | yt-dlp --format bestaudio --get-url "$url" | sed -e "s/^/file '/" | sed -e "s/$/'/"
63 | }
64 |
65 | main() {
66 | local url=$1
67 | local station=$2
68 | ffmpeg -protocol_whitelist file,http,https,tcp,tls,crypto \
69 | -f concat -safe 0 -i <(yt-dlp-to-ffconcat "$url") \
70 | -f mp3 -ar 44100 -b:a 256k - | \
71 | cstream -t 64k | \
72 | curl -s -k -H "Transfer-Encoding: chunked" -X POST -T - \
73 | "https://streammyaudio.com/$station.mp3?stream=true&advertise=true"
74 | }
75 |
76 | main "$@"
77 |
78 | 86 | Click here to download the latest release. 87 |
88 |96 | Click here to download the latest release. 97 |
98 |106 | Click here to download the latest release. 107 |
108 |Make sure you have ffmpeg installed for it to work.
117 | Its easy to build from source. First download Go for your operating system. Then you can clone and build the repostiory. 118 |
119 |
120 | > git clone https://github.com/schollz/streammyaudio
121 | > cd streammyaudio
122 | > go build -v
123 |
124 | Make sure you also have ffmpeg installed.
126 | > sudo apt install ffmpeg
127 |
128 | 131 | If you have any problems with the download please feel free to create an issue on Github. 132 |
133 | {{ template "postbody" . }} 134 | {{end}} 135 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 2 | github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= 3 | github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= 4 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 5 | github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= 6 | github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= 7 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 8 | github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= 9 | github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= 10 | github.com/dchest/captcha v1.0.0 h1:vw+bm/qMFvTgcjQlYVTuQBJkarm5R0YSsDKhm1HZI2o= 11 | github.com/dchest/captcha v1.0.0/go.mod h1:7zoElIawLp7GUMLcj54K9kbw+jEyvz2K0FDdRRYhvWo= 12 | github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc= 13 | github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc= 14 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 15 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 16 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 17 | github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= 18 | github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= 19 | github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= 20 | github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= 21 | github.com/schollz/logger v1.2.0 h1:5WXfINRs3lEUTCZ7YXhj0uN+qukjizvITLm3Ca2m0Ho= 22 | github.com/schollz/logger v1.2.0/go.mod h1:P6F4/dGMGcx8wh+kG1zrNEd4vnNpEBY/mwEMd/vn6AM= 23 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 24 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 25 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 26 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 27 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 28 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 29 | golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= 30 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 31 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 32 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 33 | golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 34 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 35 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 36 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 37 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 38 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 39 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 40 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= 41 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 42 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 43 | golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= 44 | golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= 45 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 46 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 47 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 48 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 49 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 50 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 51 | golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 52 | golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 53 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 54 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 55 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 56 | golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 57 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 58 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 59 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 60 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 61 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 62 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 63 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 64 | golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= 65 | golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 66 | golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 67 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 68 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 69 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 70 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 71 | golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= 72 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 73 | golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 74 | golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= 75 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 76 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 77 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 78 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 79 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 80 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 81 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 82 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 83 | golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= 84 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 85 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 86 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 87 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 88 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 89 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 90 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 91 | -------------------------------------------------------------------------------- /src/client/client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/url" 7 | "os" 8 | "os/exec" 9 | "os/signal" 10 | "runtime" 11 | "strings" 12 | "time" 13 | 14 | "github.com/manifoldco/promptui" 15 | "github.com/schollz/streammyaudio/src/clearscreen" 16 | "github.com/schollz/streammyaudio/src/ffmpeg" 17 | ) 18 | 19 | type Client struct { 20 | Name string 21 | Advertise string 22 | Archive string 23 | DeviceName string 24 | Device string 25 | Server string 26 | Quality int 27 | } 28 | 29 | func (c *Client) Run() (err error) { 30 | _ = ffmpeg.Binary() 31 | defer func() { 32 | ffmpeg.Clean() 33 | }() 34 | 35 | clearscreen.ClearScreen() 36 | fmt.Println("\n" + ` _______..___________..______ _______ ___ .___ ___. 37 | / || || _ \ | ____| / \ | \/ | 38 | | (----` + "`" + `` + "`" + `---| |----` + "`" + `| |_) | | |__ / ^ \ | \ / | 39 | \ \ | | | / | __| / /_\ \ | |\/| | 40 | .----) | | | | |\ \----.| |____ / _____ \ | | | | 41 | |_______/ |__| | _| ` + "`" + `._____||_______|/__/ \__\ |__| |__| 42 | .___ ___. ____ ____ ___ __ __ _______ __ ______ 43 | | \/ | \ \ / / / \ | | | | | \ | | / __ \ 44 | | \ / | \ \/ / / ^ \ | | | | | .--. || | | | | | 45 | | |\/| | \_ _/ / /_\ \ | | | | | | | || | | | | | 46 | | | | | | | / _____ \ | ` + "`" + `--' | | '--' || | | ` + "`" + `--' | 47 | |__| |__| |__| /__/ \__\ \______/ |_______/ |__| \______/ 48 | 49 | `) 50 | err = c.cast() 51 | if err != nil { 52 | fmt.Println(" no stream initiated, goodbye.") 53 | time.Sleep(1 * time.Second) 54 | err = nil 55 | } 56 | return 57 | } 58 | 59 | func (c *Client) windowsSelectAudioDevice() (cmd *exec.Cmd, err error) { 60 | cmd = exec.Command(ffmpeg.Binary(), "-list_devices", "true", "-f", "dshow", "-i", "dummy") 61 | output, err := cmd.CombinedOutput() 62 | // if err != nil { 63 | // panic(err) 64 | // } 65 | inputDevices := []string{} 66 | inputDeviceNames := []string{} 67 | 68 | for _, line := range strings.Split(string(output), "\n") { 69 | if strings.HasPrefix(line, "[dshow") { 70 | parts := strings.Fields(line) 71 | if len(parts) < 2 { 72 | continue 73 | } 74 | foo := strings.Split(line, ` "`) 75 | if len(foo) < 2 { 76 | continue 77 | } 78 | name := foo[1] 79 | name = name[:len(name)-2] 80 | if strings.HasPrefix(foo[1], "@device") { 81 | inputDeviceNames = append(inputDeviceNames, strings.TrimSpace(name)) 82 | } else { 83 | inputDevices = append(inputDevices, strings.TrimSpace(name)) 84 | } 85 | } 86 | } 87 | if len(inputDevices) != len(inputDeviceNames) { 88 | err = fmt.Errorf("devices names do not match %d!=%d", len(inputDevices), len(inputDeviceNames)) 89 | } 90 | 91 | prompt := promptui.Select{ 92 | Label: "Select input device", 93 | Items: inputDevices, 94 | Size: len(inputDevices), 95 | } 96 | 97 | var i int 98 | i, c.DeviceName, err = prompt.Run() 99 | result := inputDeviceNames[i] 100 | 101 | if err != nil { 102 | return 103 | } 104 | cmd = exec.Command(ffmpeg.Binary(), "-f", "dshow", "-i", "audio="+result, "-f", "mp3", "-q:a", fmt.Sprint(c.Quality), "-") 105 | return 106 | } 107 | 108 | func (c *Client) linuxSelectAudioDevice() (cmd *exec.Cmd, err error) { 109 | cmd = exec.Command("cat", "/proc/asound/cards") 110 | output, err := cmd.CombinedOutput() 111 | if err != nil { 112 | return 113 | } 114 | 115 | inputDeviceNames := []string{} 116 | for _, line := range strings.Split(string(output), "\n") { 117 | if strings.Contains(line, "[") { 118 | inputDeviceNames = append(inputDeviceNames, strings.TrimSpace(line)) 119 | } 120 | } 121 | 122 | prompt := promptui.Select{ 123 | Label: "Select input device", 124 | Items: inputDeviceNames, 125 | Size: len(inputDeviceNames), 126 | } 127 | 128 | var i int 129 | i, c.DeviceName, err = prompt.Run() 130 | 131 | if err != nil { 132 | return 133 | } 134 | cmd = exec.Command("ffmpeg", "-f", "alsa", "-i", fmt.Sprintf("hw:%d", i), "-f", "mp3", "-q:a", fmt.Sprint(c.Quality), "-") 135 | return 136 | } 137 | 138 | func (c *Client) darwinSelectAudioDevice() (cmd *exec.Cmd, err error) { 139 | cmd = exec.Command(ffmpeg.Binary(), "-f", "avfoundation", "-list_devices", "true", "-i", "dummy") 140 | output, _ := cmd.CombinedOutput() 141 | inputDeviceNames := []string{} 142 | 143 | haveAudioDevices := false 144 | for _, line := range strings.Split(string(output), "\n") { 145 | if strings.Contains(line, "audio devices") { 146 | haveAudioDevices = true 147 | continue 148 | } 149 | if strings.Contains(line, "AVFoundation") && haveAudioDevices { 150 | parts := strings.Split(line, "]") 151 | if len(parts) < 2 { 152 | continue 153 | } 154 | name := parts[len(parts)-1] 155 | inputDeviceNames = append(inputDeviceNames, strings.TrimSpace(name)) 156 | } 157 | } 158 | 159 | prompt := promptui.Select{ 160 | Label: "Select input device", 161 | Items: inputDeviceNames, 162 | Size: len(inputDeviceNames), 163 | } 164 | 165 | var i int 166 | i, c.DeviceName, err = prompt.Run() 167 | if err != nil { 168 | return 169 | } 170 | cmd = exec.Command(ffmpeg.Binary(), "-f", "avfoundation", "-i", fmt.Sprintf(":%d", i), "-f", "mp3", "-q:a", fmt.Sprint(c.Quality), "-") 171 | return 172 | } 173 | 174 | func (c *Client) cast() (err error) { 175 | err = c.getStreamInfo() 176 | if err != nil { 177 | return 178 | } 179 | 180 | var cmd *exec.Cmd 181 | switch runtime.GOOS { 182 | case "windows": 183 | cmd, err = c.windowsSelectAudioDevice() 184 | case "darwin": 185 | cmd, err = c.darwinSelectAudioDevice() 186 | case "linux": 187 | cmd, err = c.linuxSelectAudioDevice() 188 | default: 189 | fmt.Printf("%s.\n", runtime.GOOS) 190 | } 191 | if err != nil { 192 | return 193 | } 194 | 195 | canceled := false 196 | cc := make(chan os.Signal, 1) 197 | signal.Notify(cc, os.Interrupt) 198 | go func() { 199 | for range cc { 200 | canceled = true 201 | // sig is a ^C, handle it 202 | cmd.Process.Kill() 203 | } 204 | }() 205 | // output, err := cmd.CombinedOutput() 206 | // fmt.Println(string(output)) 207 | // fmt.Println(err) 208 | stdout, err := cmd.StdoutPipe() 209 | if err != nil { 210 | return 211 | } 212 | err = cmd.Start() 213 | if err != nil { 214 | return 215 | } 216 | tr := http.DefaultTransport 217 | client := &http.Client{ 218 | Transport: tr, 219 | Timeout: 0, 220 | } 221 | r := stdout 222 | req := &http.Request{ 223 | Method: "POST", 224 | URL: &url.URL{ 225 | Scheme: strings.Split(c.Server, "://")[0], 226 | Host: strings.Split(c.Server, "://")[1], 227 | Path: "/" + c.Name + ".mp3", 228 | RawQuery: "stream=true&advertise=" + c.Advertise + "&archive=" + c.Archive, 229 | }, 230 | ProtoMajor: 1, 231 | ProtoMinor: 1, 232 | ContentLength: -1, 233 | Body: r, 234 | } 235 | 236 | go func() { 237 | _, err = client.Do(req) 238 | if err != nil && !canceled { 239 | fmt.Println("problem connecting: %s", err.Error()) 240 | cmd.Process.Kill() 241 | } 242 | }() 243 | 244 | fmt.Printf("\n\nnow streaming at\n") 245 | fmt.Printf("\n%s/%s\n\n", c.Server, c.Name) 246 | fmt.Printf("press Ctl+C to quit\n") 247 | 248 | cmd.Wait() 249 | fmt.Println("goodbye.") 250 | time.Sleep(1 * time.Second) 251 | return 252 | } 253 | 254 | func (c *Client) getStreamInfo() (err error) { 255 | validate := func(input string) error { 256 | if strings.TrimSpace(input) == "" { 257 | return fmt.Errorf("name cannot be empty") 258 | } 259 | return nil 260 | } 261 | 262 | if c.Name == "" { 263 | prompt := promptui.Prompt{ 264 | Label: "Enter stream name: ", 265 | Validate: validate, 266 | } 267 | 268 | c.Name, err = prompt.Run() 269 | if err != nil { 270 | return 271 | } 272 | 273 | } 274 | 275 | if c.Quality < 0 || c.Quality > 9 { 276 | prompt2 := promptui.Select{ 277 | Label: "select quality", 278 | Items: []string{"best (260 kbps)", "good (165 kbps)", "poor (85 kbps)"}, 279 | } 280 | var q int 281 | q, _, err = prompt2.Run() 282 | if err != nil { 283 | return 284 | } 285 | c.Quality = q 286 | if q == 1 { 287 | c.Quality = 4 288 | } else if q == 2 { 289 | c.Quality = 8 290 | } 291 | } 292 | 293 | if c.Advertise == "" { 294 | prompt2 := promptui.Select{ 295 | Label: "advertise stream?", 296 | Items: []string{"no advertise", "yes advertise"}, 297 | } 298 | _, c.Advertise, err = prompt2.Run() 299 | if err != nil { 300 | return 301 | } 302 | 303 | } 304 | if strings.Contains(c.Advertise, "yes") { 305 | c.Advertise = "true" 306 | } else { 307 | c.Advertise = "false" 308 | } 309 | 310 | if c.Archive == "" { 311 | prompt3 := promptui.Select{ 312 | Label: "archive stream (keep after finished)?", 313 | Items: []string{"no archive", "yes archive"}, 314 | } 315 | _, c.Archive, err = prompt3.Run() 316 | if err != nil { 317 | return 318 | } 319 | } 320 | if strings.Contains(c.Archive, "yes") { 321 | c.Archive = "true" 322 | } else { 323 | c.Archive = "false" 324 | } 325 | return 326 | } 327 | -------------------------------------------------------------------------------- /src/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "io" 7 | "math/rand" 8 | "net/http" 9 | "os" 10 | "path" 11 | "path/filepath" 12 | "sort" 13 | "strings" 14 | "sync" 15 | "text/template" 16 | "time" 17 | 18 | "github.com/dchest/captcha" 19 | "github.com/gabriel-vasile/mimetype" 20 | "github.com/h2non/filetype" 21 | log "github.com/schollz/logger" 22 | "github.com/schollz/streammyaudio/src/chat" 23 | "github.com/schollz/streammyaudio/src/filecreated" 24 | ) 25 | 26 | //go:embed template 27 | var templateFiles embed.FS 28 | 29 | //go:embed static/* 30 | var staticContent embed.FS 31 | 32 | type Server struct { 33 | Port int 34 | Folder string 35 | } 36 | 37 | type stream struct { 38 | b []byte 39 | done bool 40 | } 41 | 42 | type view struct { 43 | Title string 44 | Page string 45 | FileNoExt string 46 | Items []string 47 | Captcha string 48 | Rand string 49 | Archived []ArchivedFile 50 | Message string 51 | StripMP3 func(string) string 52 | } 53 | 54 | // Serve will start the server 55 | func (s *Server) Run() (err error) { 56 | go chat.Run() 57 | 58 | tmpl := template.Must(template.ParseFS(templateFiles, "template/*")) 59 | 60 | channels := make(map[string]map[float64]chan stream) 61 | archived := make(map[string]*os.File) 62 | advertisements := make(map[string]bool) 63 | mutex := &sync.Mutex{} 64 | 65 | servePage := func(w http.ResponseWriter, r *http.Request, page string, msg string) (err error) { 66 | data := view{ 67 | Page: page, 68 | Message: msg, 69 | Rand: fmt.Sprintf("%d", rand.Int31()), 70 | StripMP3: func(s string) string { 71 | return strings.TrimSuffix(s, ".mp3") 72 | }, 73 | } 74 | 75 | switch page { 76 | case "live": 77 | adverts := []string{} 78 | mutex.Lock() 79 | for advert := range advertisements { 80 | adverts = append(adverts, strings.TrimPrefix(advert, "/")) 81 | } 82 | mutex.Unlock() 83 | data.Items = adverts 84 | case "archive": 85 | active := make(map[string]struct{}) 86 | data.Archived = s.listArchived(active) 87 | data.Captcha = captcha.New() 88 | } 89 | log.Debugf("%s data: %+v", page, data) 90 | err = tmpl.ExecuteTemplate(w, page, data) 91 | if err != nil { 92 | panic(err) 93 | } 94 | return 95 | } 96 | handler := func(w http.ResponseWriter, r *http.Request) { 97 | w.Header().Set("Access-Control-Allow-Origin", "*") 98 | w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE") 99 | w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization") 100 | 101 | log.Debugf("opened %s %s", r.Method, r.URL.Path) 102 | defer func() { 103 | log.Debugf("finished %s\n", r.URL.Path) 104 | }() 105 | 106 | if r.URL.Path == "/" { 107 | servePage(w, r, "sya", "") 108 | return 109 | } else if r.URL.Path == "/favicon.ico" { 110 | w.WriteHeader(http.StatusOK) 111 | return 112 | } else if r.URL.Path == "/ws" { 113 | chat.Serve(w, r) 114 | return 115 | } else if r.Method == "POST" && strings.HasPrefix(r.URL.Path, "/archive") { 116 | filename := filepath.Clean(strings.TrimPrefix(r.FormValue("filename"), s.Folder)) 117 | // This extra join implicitly does a clean and thereby prevents directory traversal 118 | filename = path.Join("/", filename) 119 | filename = path.Join(s.Folder, filename) 120 | action := r.FormValue("action") 121 | log.Debugf("%s %s", action, filename) 122 | if !captcha.VerifyString(r.FormValue("captchaId"), r.FormValue("captchaSolution")) { 123 | servePage(w, r, "archive", fmt.Sprintf("Incorrect captcha, could not %s '%s'", action, filename)) 124 | return 125 | } 126 | msg := "" 127 | if action == "remove" { 128 | os.Remove(filename) 129 | msg = fmt.Sprintf("Removed '%s", filename) 130 | } else if action == "rename" { 131 | newname := strings.TrimSpace(r.FormValue("newname")) 132 | if !strings.HasSuffix(newname, ".mp3") { 133 | servePage(w, r, "archive", "Cannot rename suffix '.mp3'.") 134 | return 135 | } 136 | // This join with "/" prevents directory traversal with an implicit clean 137 | newname = path.Join("/", newname) 138 | newname = path.Join(s.Folder, newname) 139 | os.Rename(filename, newname) 140 | filename = strings.TrimPrefix(filename, "archived/") 141 | newname = strings.TrimPrefix(newname, "archived/") 142 | msg = fmt.Sprintf("Renamed '%s' to '%s'.", filename, newname) 143 | } 144 | servePage(w, r, "archive", msg) 145 | return 146 | } else if r.URL.Path == "/live" || 147 | r.URL.Path == "/archive" || 148 | r.URL.Path == "/download" { 149 | log.Debugf("serving page %s", r.URL.Path) 150 | servePage(w, r, r.URL.Path[1:], "") 151 | return 152 | 153 | } else if strings.HasPrefix(r.URL.Path, "/static/") { 154 | filename := filepath.Clean(strings.TrimPrefix(r.URL.Path, "/static/")) 155 | // This extra join implicitly does a clean and thereby prevents directory traversal 156 | filename = path.Join("/", filename) 157 | filename = path.Join("static", filename) 158 | log.Debugf("serving %s", filename) 159 | p, err := staticContent.ReadFile(filename) 160 | if err != nil { 161 | log.Error(err) 162 | } 163 | w.Write(p) 164 | return 165 | } else if !strings.HasSuffix(r.URL.Path, ".mp3") { 166 | data := view{ 167 | Page: "live", 168 | FileNoExt: r.URL.Path[1:], 169 | Rand: fmt.Sprintf("%d", rand.Int31()), 170 | } 171 | err = tmpl.ExecuteTemplate(w, "chat", data) 172 | if err != nil { 173 | panic(err) 174 | } 175 | 176 | return 177 | } 178 | 179 | v, ok := r.URL.Query()["stream"] 180 | doStream := ok && v[0] == "true" 181 | 182 | v, ok = r.URL.Query()["archive"] 183 | doArchive := ok && v[0] == "true" 184 | 185 | if doArchive && r.Method == "POST" { 186 | if _, ok := archived[r.URL.Path]; !ok { 187 | folderName := path.Join(s.Folder, time.Now().Format("200601021504")) 188 | os.MkdirAll(folderName, os.ModePerm) 189 | archived[r.URL.Path], err = os.Create(path.Join(folderName, strings.TrimPrefix(r.URL.Path, "/"))) 190 | if err != nil { 191 | log.Error(err) 192 | } 193 | } 194 | defer func() { 195 | mutex.Lock() 196 | if _, ok := archived[r.URL.Path]; ok { 197 | log.Debugf("closed archive for %s", r.URL.Path) 198 | archived[r.URL.Path].Close() 199 | delete(archived, r.URL.Path) 200 | } 201 | mutex.Unlock() 202 | }() 203 | } 204 | 205 | v, ok = r.URL.Query()["advertise"] 206 | log.Debugf("advertise: %+v", v) 207 | if ok && v[0] == "true" && doStream { 208 | mutex.Lock() 209 | advertisements[r.URL.Path] = true 210 | mutex.Unlock() 211 | defer func() { 212 | mutex.Lock() 213 | delete(advertisements, r.URL.Path) 214 | mutex.Unlock() 215 | }() 216 | } 217 | 218 | mutex.Lock() 219 | if _, ok := channels[r.URL.Path]; !ok { 220 | channels[r.URL.Path] = make(map[float64]chan stream) 221 | } 222 | mutex.Unlock() 223 | 224 | if r.Method == "GET" { 225 | id := rand.Float64() 226 | mutex.Lock() 227 | channels[r.URL.Path][id] = make(chan stream, 30) 228 | channel := channels[r.URL.Path][id] 229 | log.Debugf("added listener %f", id) 230 | mutex.Unlock() 231 | 232 | w.Header().Set("Connection", "keep-alive") 233 | w.Header().Set("Pragma", "no-cache") 234 | w.Header().Set("Cache-Control", "no-cache, no-store") 235 | 236 | mimetyped := false 237 | canceled := false 238 | for { 239 | select { 240 | case s := <-channel: 241 | if s.done { 242 | canceled = true 243 | } else { 244 | if !mimetyped { 245 | mimetyped = true 246 | mimetype := mimetype.Detect(s.b).String() 247 | if mimetype == "application/octet-stream" { 248 | ext := strings.TrimPrefix(filepath.Ext(r.URL.Path), ".") 249 | log.Debug("checking extension %s", ext) 250 | mimetype = filetype.GetType(ext).MIME.Value 251 | } 252 | w.Header().Set("Content-Type", mimetype) 253 | log.Debugf("serving as Content-Type: '%s'", mimetype) 254 | } 255 | w.Write(s.b) 256 | w.(http.Flusher).Flush() 257 | } 258 | case <-r.Context().Done(): 259 | log.Debug("consumer canceled") 260 | canceled = true 261 | } 262 | if canceled { 263 | break 264 | } 265 | } 266 | 267 | mutex.Lock() 268 | delete(channels[r.URL.Path], id) 269 | log.Debugf("removed listener %f", id) 270 | mutex.Unlock() 271 | close(channel) 272 | } else if r.Method == "POST" { 273 | buffer := make([]byte, 2048) 274 | cancel := true 275 | isdone := false 276 | lifetime := 0 277 | for { 278 | if !doStream { 279 | select { 280 | case <-r.Context().Done(): 281 | isdone = true 282 | default: 283 | } 284 | if isdone { 285 | log.Debug("is done") 286 | break 287 | } 288 | mutex.Lock() 289 | numListeners := len(channels[r.URL.Path]) 290 | mutex.Unlock() 291 | if numListeners == 0 { 292 | time.Sleep(1 * time.Second) 293 | lifetime++ 294 | if lifetime > 600 { 295 | isdone = true 296 | } 297 | continue 298 | } 299 | } 300 | n, err := r.Body.Read(buffer) 301 | if err != nil { 302 | log.Debugf("err: %s", err) 303 | if err == io.ErrUnexpectedEOF { 304 | cancel = false 305 | } 306 | break 307 | } 308 | if doArchive { 309 | mutex.Lock() 310 | archived[r.URL.Path].Write(buffer[:n]) 311 | mutex.Unlock() 312 | } 313 | mutex.Lock() 314 | channels_current := channels[r.URL.Path] 315 | mutex.Unlock() 316 | for _, c := range channels_current { 317 | var b2 = make([]byte, n) 318 | copy(b2, buffer[:n]) 319 | c <- stream{b: b2} 320 | } 321 | } 322 | if cancel { 323 | mutex.Lock() 324 | channels_current := channels[r.URL.Path] 325 | mutex.Unlock() 326 | for _, c := range channels_current { 327 | c <- stream{done: true} 328 | } 329 | } 330 | } else { 331 | w.WriteHeader(http.StatusOK) 332 | } 333 | } 334 | 335 | log.Infof("running on port %d", s.Port) 336 | http.Handle("/archived/", http.FileServer(http.Dir("."))) 337 | http.Handle("/captcha/", captcha.Server(captcha.StdWidth, captcha.StdHeight)) 338 | http.HandleFunc("/", handler) 339 | err = http.ListenAndServe(fmt.Sprintf(":%d", s.Port), nil) 340 | if err != nil { 341 | log.Error(err) 342 | } 343 | return 344 | } 345 | 346 | type ArchivedFile struct { 347 | Filename string 348 | FullFilename string 349 | Created time.Time 350 | } 351 | 352 | func (s *Server) listArchived(active map[string]struct{}) (afiles []ArchivedFile) { 353 | fnames := []string{} 354 | err := filepath.Walk(s.Folder, 355 | func(path string, info os.FileInfo, err error) error { 356 | if err != nil { 357 | return err 358 | } 359 | if !info.IsDir() { 360 | fnames = append(fnames, path) 361 | } 362 | return nil 363 | }) 364 | if err != nil { 365 | return 366 | } 367 | for _, fname := range fnames { 368 | _, onlyfname := path.Split(fname) 369 | created := filecreated.FileCreated(fname) 370 | if _, ok := active[onlyfname]; !ok { 371 | afiles = append(afiles, ArchivedFile{ 372 | Filename: onlyfname, 373 | FullFilename: fname, 374 | Created: created, 375 | }) 376 | } 377 | } 378 | 379 | sort.Slice(afiles, func(i, j int) bool { 380 | return afiles[i].Created.After(afiles[j].Created) 381 | }) 382 | 383 | return 384 | } 385 | -------------------------------------------------------------------------------- /src/server/template/template.html: -------------------------------------------------------------------------------- 1 | {{ define "prebody"}} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |