├── go.mod ├── model.go ├── static ├── css │ └── style.css ├── index.html ├── js │ └── main.js └── vendor │ └── axios.min.js ├── http ├── livestream_updates.go ├── response.go ├── static.go ├── websockets │ ├── connection.go │ └── pool.go ├── server.go ├── handler.go └── stream.go ├── Dockerfile ├── go.sum ├── LICENSE ├── cmd └── pxy │ └── main.go ├── rtmp └── broadcast.go └── README.md /go.mod: -------------------------------------------------------------------------------- 1 | module pxy 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/google/uuid v1.1.1 7 | github.com/gorilla/mux v1.7.4 8 | github.com/gorilla/websocket v1.4.2 9 | ) 10 | -------------------------------------------------------------------------------- /model.go: -------------------------------------------------------------------------------- 1 | package pxy 2 | 3 | // BroadcastService ... 4 | type BroadcastService interface { 5 | StartBroadcast() error 6 | StopBroadcast() error 7 | PipeToBroadcast(stream *[]byte) 8 | } 9 | -------------------------------------------------------------------------------- /static/css/style.css: -------------------------------------------------------------------------------- 1 | #app { 2 | margin: 2%; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, 4 | Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; 5 | } 6 | 7 | .hide { 8 | display: none; 9 | } -------------------------------------------------------------------------------- /http/livestream_updates.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import "net/http" 4 | 5 | // LivestreamUpdates ... 6 | type LivestreamUpdates interface { 7 | RegisterStreamer(sessionID, userID, streamKey string, w http.ResponseWriter, r *http.Request) error 8 | RemoveStreamer(sessionID string) error 9 | StreamerIsRegisteredForSession(sessionID string) bool 10 | } 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.14.2-alpine3.11 2 | 3 | # Install FFmpeg dependencies 4 | RUN apk add --no-cache ffmpeg 5 | 6 | # Set working directory 7 | WORKDIR /usr/src/app 8 | 9 | # Copy application code into container 10 | COPY . . 11 | 12 | # Build pyx binaries 13 | RUN go build -o pxy ./cmd/pxy/main.go 14 | 15 | # Expose port 16 | EXPOSE 8080 17 | 18 | # Start App 19 | CMD ["./pxy"] -------------------------------------------------------------------------------- /http/response.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | ) 7 | 8 | type standardResponse struct { 9 | Message string `json:"message"` 10 | } 11 | 12 | func writeJSONMessage(w http.ResponseWriter, statusCode int, body interface{}) { 13 | w.Header().Add("Content-Type", "application/json") 14 | w.WriteHeader(statusCode) 15 | json.NewEncoder(w).Encode(body) 16 | } 17 | -------------------------------------------------------------------------------- /http/static.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gorilla/mux" 7 | ) 8 | 9 | // StaticHandler ... 10 | type StaticHandler struct { 11 | *mux.Router 12 | } 13 | 14 | // NewStaticHandler ... 15 | func NewStaticHandler() *StaticHandler { 16 | h := &StaticHandler{ 17 | Router: mux.NewRouter(), 18 | } 19 | 20 | h.Router.PathPrefix("/").Handler(http.FileServer(http.Dir("./static/"))) 21 | 22 | return h 23 | } 24 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= 2 | github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 3 | github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc= 4 | github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 5 | github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= 6 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 7 | -------------------------------------------------------------------------------- /http/websockets/connection.go: -------------------------------------------------------------------------------- 1 | package websockets 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | "github.com/gorilla/websocket" 6 | ) 7 | 8 | type livestreamConnection struct { 9 | ID string 10 | SessionID string 11 | UserID string 12 | StreamKey string 13 | Websocket *websocket.Conn 14 | } 15 | 16 | func newLivestreamConnection(sessionID, userID, streamKey string, conn *websocket.Conn) *livestreamConnection { 17 | return &livestreamConnection{ 18 | ID: uuid.New().String(), 19 | SessionID: sessionID, 20 | UserID: userID, 21 | StreamKey: streamKey, 22 | Websocket: conn, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /http/server.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net" 5 | "net/http" 6 | ) 7 | 8 | // Server represents a HTTP server. 9 | type Server struct { 10 | ln net.Listener 11 | Handler *Handler 12 | Addr string 13 | } 14 | 15 | // Open opens a socket and serves the HTTP server. 16 | func (s *Server) Open() error { 17 | ln, err := net.Listen("tcp", s.Addr) 18 | if err != nil { 19 | return err 20 | } 21 | s.ln = ln 22 | 23 | go func() { http.Serve(s.ln, s.Handler) }() 24 | 25 | return nil 26 | } 27 | 28 | // Close closes the socket. 29 | func (s *Server) Close() error { 30 | if s.ln != nil { 31 | s.ln.Close() 32 | } 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /http/handler.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | ) 7 | 8 | // Handler implements the http.Handler interface and serves as the main handler for the server 9 | // by redirecting requests to sub-handlers. 10 | type Handler struct { 11 | StreamHandler *StreamHandler 12 | StaticHandler *StaticHandler 13 | } 14 | 15 | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 16 | urlSegments := strings.Split(r.URL.Path, "/") 17 | 18 | if len(urlSegments) > 3 && urlSegments[1] == "api" { 19 | resourceName := urlSegments[3] 20 | 21 | switch resourceName { 22 | case "stream": 23 | h.StreamHandler.ServeHTTP(w, r) 24 | break 25 | default: 26 | http.NotFound(w, r) 27 | break 28 | } 29 | 30 | return 31 | } 32 | 33 | h.StaticHandler.ServeHTTP(w, r) 34 | } 35 | -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Pyx Demo 8 | 9 | 10 | 11 |
12 |

pyx demo

13 |
14 | 15 | 16 |
17 | 18 |
19 | 20 |
21 | 22 |
23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Chua Bing Quan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /cmd/pxy/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "os/signal" 7 | "pxy/http" 8 | "pxy/http/websockets" 9 | ) 10 | 11 | const ( 12 | readBufferSize = 1024 13 | writeBufferSize = 1024 14 | publishURL = "rtmp://global-live.mux.com:5222/app" 15 | ) 16 | 17 | var ( 18 | subprotocols = []string{"streamKey"} 19 | ) 20 | 21 | func main() { 22 | port := os.Getenv("PORT") 23 | if port == "" { 24 | port = "8080" 25 | } 26 | 27 | livestreamPool := websockets.NewLivestreamPool( 28 | readBufferSize, 29 | writeBufferSize, 30 | subprotocols, 31 | publishURL, 32 | ) 33 | 34 | streamHandler := http.NewStreamHandler(livestreamPool) 35 | staticHandler := http.NewStaticHandler() 36 | 37 | handler := http.Handler{ 38 | StreamHandler: streamHandler, 39 | StaticHandler: staticHandler, 40 | } 41 | 42 | server := http.Server{Handler: &handler, Addr: ":" + port} 43 | err := server.Open() 44 | if err != nil { 45 | log.Fatalln("Failed to start server: %w", err) 46 | } else { 47 | log.Println("Server is running.") 48 | } 49 | 50 | // Block until an interrupt signal is received to keep server alive. 51 | c := make(chan os.Signal, 1) 52 | signal.Notify(c, os.Interrupt) 53 | s := <-c 54 | log.Println("Got signal:", s) 55 | } 56 | -------------------------------------------------------------------------------- /http/stream.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/gorilla/mux" 9 | ) 10 | 11 | // StreamHandler .. 12 | type StreamHandler struct { 13 | *mux.Router 14 | livestreamUpdates LivestreamUpdates 15 | } 16 | 17 | // NewStreamHandler ... 18 | func NewStreamHandler(livestreamUpdates LivestreamUpdates) *StreamHandler { 19 | h := &StreamHandler{ 20 | Router: mux.NewRouter(), 21 | livestreamUpdates: livestreamUpdates, 22 | } 23 | 24 | h.HandleFunc("/api/v0/stream", h.handleCreateLivestream).Methods("GET") 25 | 26 | return h 27 | } 28 | 29 | func (sh *StreamHandler) handleCreateLivestream(w http.ResponseWriter, r *http.Request) { 30 | sessionID := mux.Vars(r)["sessionID"] 31 | userID := "some_user_id" 32 | 33 | streamKey, err := getWebsocketStreamKey(r) 34 | if err != nil { 35 | writeJSONMessage(w, http.StatusBadRequest, standardResponse{"Please supply a valid stream key"}) 36 | return 37 | } 38 | 39 | err = sh.livestreamUpdates.RegisterStreamer(sessionID, userID, streamKey, w, r) 40 | if err != nil { 41 | writeJSONMessage(w, http.StatusInternalServerError, standardResponse{"Something unexpected went wrong"}) 42 | return 43 | } 44 | } 45 | 46 | func getWebsocketStreamKey(r *http.Request) (string, error) { 47 | subprotocols := strings.Split(r.Header.Get("Sec-Websocket-Protocol"), ", ") 48 | if len(subprotocols) != 2 || subprotocols[0] != "streamKey" { 49 | return "", errors.New("Stream key not found in header") 50 | } 51 | return subprotocols[1], nil 52 | } 53 | -------------------------------------------------------------------------------- /rtmp/broadcast.go: -------------------------------------------------------------------------------- 1 | package rtmp 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | ) 8 | 9 | // Client ... 10 | type Client struct { 11 | RtmpURL string 12 | StreamKey string 13 | streamPipe *chan *[]byte 14 | ffmpeg *exec.Cmd 15 | } 16 | 17 | var ffmpegArgs = []string{ 18 | "-i", "-", 19 | "-vcodec", "copy", 20 | "-f", "flv", 21 | } 22 | 23 | // NewRTMPClient ... 24 | func NewRTMPClient(rtmpURL, streamKey string) *Client { 25 | streamPipe := make(chan *[]byte) 26 | return &Client{ 27 | RtmpURL: rtmpURL, 28 | StreamKey: streamKey, 29 | streamPipe: &streamPipe, 30 | ffmpeg: exec.Command("ffmpeg", append(ffmpegArgs, rtmpURL+"/"+streamKey)...), 31 | } 32 | } 33 | 34 | // StartBroadcast ... 35 | func (c *Client) StartBroadcast() error { 36 | c.ffmpeg.Stderr = os.Stderr 37 | 38 | ffmpegInput, err := c.ffmpeg.StdinPipe() 39 | if err != nil { 40 | return fmt.Errorf("Failed to get input pipe for FFmpeg process: %w", err) 41 | } 42 | 43 | err = c.ffmpeg.Start() 44 | if err != nil { 45 | ffmpegInput.Close() 46 | return fmt.Errorf("Failed to start FFmpeg process: %w", err) 47 | } 48 | 49 | go func() { 50 | defer ffmpegInput.Close() 51 | defer c.ffmpeg.Wait() 52 | 53 | for { 54 | stream := <-*c.streamPipe 55 | 56 | _, err := ffmpegInput.Write(*stream) 57 | if err != nil { 58 | break 59 | } 60 | } 61 | }() 62 | 63 | return nil 64 | } 65 | 66 | // StopBroadcast ... 67 | func (c *Client) StopBroadcast() error { 68 | err := c.ffmpeg.Process.Kill() 69 | if err != nil { 70 | return fmt.Errorf("Failed to kill FFmpeg process: %w", err) 71 | } 72 | 73 | return nil 74 | } 75 | 76 | // PipeToBroadcast ... 77 | func (c *Client) PipeToBroadcast(stream *[]byte) { 78 | *c.streamPipe <- stream 79 | } 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pxy 2 | `pxy` is a Go server that routes incoming livestream data from websockets to an external RTMP endpoint. 3 | 4 | __This project is a work in progress, I'll update it more with time.__ 5 | 6 | ## Context 7 | For a side-project of mine, I've to broadcast live streams through an external service that uses the RTMP protocol. Unfortunately, my front-ends (Flutter and all web browsers out there) do not support the RTMP protocol. Therefore, I built `pxy` to proxy the live streams from such clients (via websockets) to the broadcasting RTMP servers. Since RTMP is still widely used in the video streaming industry, I thought amateurs like myself could benefit from an implementation like `pxy` for our own live streaming side-projects. 8 | 9 | ## Status 10 | Fundamentally, `pxy` works well so far. However, there are probably still bugs that needs be ironed out. If you do find any, feel free to open an issue or make a pull request. Meanwhile, you could use `pxy` as a reference for implementing your own websocket-RTMP proxy. Alternatively, you could clone this project and modify it to suit your needs. 11 | 12 | ## Try it Out 13 | `pxy` can be setup in two ways, with, or without Docker. Head to the respective sections after completing the preliminaries for more instructions on your preferred way to setup. 14 | 15 | ### Preliminaries 16 | Clone and navigate to the repository's root. 17 | ```bash 18 | git clone https://github.com/chuabingquan/pxy.git && cd pxy-master/ 19 | ``` 20 | 21 | Update your RTMP endpoint address under the constants in `cmd/pxy/main.go`. 22 | ```go 23 | const ( 24 | readBufferSize = 1024 25 | writeBufferSize = 1024 26 | publishURL = "rtmp://global-live.mux.com:5222/app" // This one here. 27 | ) 28 | ``` 29 | 30 | ### Setup pxy with Docker (Recommended) 31 | _Before proceeding, please ensure that Docker is already installed on your machine._ 32 | 33 | Start by building the `pxy` docker image. 34 | ```bash 35 | docker build -t pxy . 36 | ``` 37 | 38 | Proceed to run a container that's based on the built `pxy` docker image. 39 | ```bash 40 | docker run --rm -p 8080:8080 pxy 41 | ``` 42 | 43 | 44 | ### Setup pxy without Docker 45 | _Before proceeding, please install FFmpeg on your machine (please Google how to)._ 46 | 47 | Build and run the `pxy` server. 48 | ```bash 49 | go run cmd/pxy/main.go 50 | ``` 51 | 52 | ### Final Steps 53 | Once your `pyx` instance is up and running, access `http://localhost:8080` from your browser (preferably Chrome) and supply your stream name/stream key. 54 | 55 | On the backend, `pxy` will append the given stream name/stream key behind the RTMP endpoint address that's defined in the preliminaries section of this README `(e.g. rtmp://global-live.mux.com:5222/app/)`. 56 | 57 | At this point, your RTMP endpoint should receive the proxied live stream from `pxy`. Playback (viewing) of the live stream is dependent on the tools/services that's hosting your RTMP endpoint. 58 | 59 | ## References 60 | - [The state of going live from a browser](https://mux.com/blog/the-state-of-going-live-from-a-browser/) 61 | - [Streaming to Facebook Live from a canvas](https://github.com/fbsamples/Canvas-Streaming-Example/blob/master/README.md) -------------------------------------------------------------------------------- /static/js/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | document.addEventListener('DOMContentLoaded', async () => { 4 | const video = document.querySelector('video'); 5 | const startBtn = document.getElementById('start-btn'); 6 | const stopBtn = document.getElementById('stop-btn'); 7 | const startControls = document.getElementById('start-controls'); 8 | const endControls = document.getElementById('end-controls'); 9 | const constraints = { 10 | audio: true, 11 | video: true, 12 | }; 13 | let websocket = null; 14 | let mediaRecorder = null; 15 | let streamKey = ''; 16 | const wsUrl = 'ws://localhost:8080/api/v0/stream'; 17 | 18 | const initCamera = async (constraints, videoEl) => { 19 | try { 20 | const stream = await navigator.mediaDevices.getUserMedia(constraints); 21 | videoEl.srcObject = stream; 22 | 23 | const mediaRecorder = new MediaRecorder(stream, { 24 | mimeType: 'video/webm;codecs=h264', 25 | bitsPerSecond: 256 * 8 * 1024 26 | }); 27 | 28 | return mediaRecorder; 29 | } catch (err) { 30 | throw err; 31 | } 32 | }; 33 | 34 | const connect = (url, streamKey) => { 35 | const ws = new WebSocket(url, ['streamKey', streamKey]); 36 | 37 | ws.onopen = (event) => { 38 | console.log(`Connection opened: ${JSON.stringify(event)}`); 39 | }; 40 | 41 | ws.onclose = (event) => { 42 | console.log(`Connection closed: ${JSON.stringify(event)}`); 43 | }; 44 | 45 | ws.onerror = (event) => { 46 | console.log(`An error occurred with websockets: ${JSON.stringify(event)}`); 47 | }; 48 | 49 | return ws; 50 | }; 51 | 52 | const toggleControls = (livestreamStarted) => { 53 | if (livestreamStarted) { 54 | startControls.classList.add('hide'); 55 | endControls.classList.remove('hide'); 56 | } else { 57 | startControls.classList.remove('hide'); 58 | endControls.classList.add('hide'); 59 | } 60 | }; 61 | 62 | startBtn.addEventListener('click', async () => { 63 | try { 64 | const streamKeyInput = document.getElementById('stream-key-input'); 65 | const streamKey = streamKeyInput.value; 66 | 67 | if (streamKey.trim() === '') { 68 | alert('Please enter a valid stream key!'); 69 | return; 70 | } 71 | 72 | websocket = connect(wsUrl, streamKey); 73 | toggleControls(true); 74 | 75 | if (mediaRecorder === null) { 76 | websocket.close(); 77 | websocket = null; 78 | toggleControls(false); 79 | return; 80 | } 81 | 82 | mediaRecorder.addEventListener('dataavailable', (e) => { 83 | websocket.send(e.data); 84 | }); 85 | 86 | mediaRecorder.addEventListener('stop', () => { 87 | websocket.close(); 88 | websocket = null; 89 | toggleControls(false); 90 | }); 91 | 92 | mediaRecorder.start(1000); 93 | } catch (err) { 94 | alert(err); 95 | } 96 | }); 97 | 98 | stopBtn.addEventListener('click', async () => { 99 | try { 100 | mediaRecorder.stop(); 101 | toggleControls(false); 102 | } catch (err) { 103 | alert(err); 104 | } 105 | }); 106 | 107 | try { 108 | mediaRecorder = await initCamera(constraints, video); 109 | } catch (err) { 110 | console.log(err); 111 | } 112 | }); -------------------------------------------------------------------------------- /http/websockets/pool.go: -------------------------------------------------------------------------------- 1 | package websockets 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "pxy" 8 | "pxy/rtmp" 9 | "sync" 10 | 11 | "github.com/gorilla/websocket" 12 | ) 13 | 14 | var ( 15 | // ErrSessionDoesNotExist ... 16 | ErrSessionDoesNotExist = errors.New("Livestream session does not exist") 17 | ) 18 | 19 | type livestream struct { 20 | Streamer *livestreamConnection 21 | RTMPClient pxy.BroadcastService 22 | } 23 | 24 | // LivestreamPool ... 25 | type LivestreamPool struct { 26 | publishURL string 27 | connections map[string]*livestream 28 | upgrader websocket.Upgrader 29 | lock sync.RWMutex 30 | } 31 | 32 | // NewLivestreamPool ... 33 | func NewLivestreamPool(readBufferSize, writeBufferSize int, subprotocols []string, publishURL string) *LivestreamPool { 34 | return &LivestreamPool{ 35 | publishURL: publishURL, 36 | connections: make(map[string]*livestream), 37 | upgrader: websocket.Upgrader{ 38 | ReadBufferSize: readBufferSize, 39 | WriteBufferSize: writeBufferSize, 40 | Subprotocols: subprotocols, 41 | CheckOrigin: func(r *http.Request) bool { return true }, 42 | }, 43 | lock: sync.RWMutex{}, 44 | } 45 | } 46 | 47 | // RegisterStreamer ... 48 | func (lp *LivestreamPool) RegisterStreamer(sessionID, userID, streamKey string, w http.ResponseWriter, r *http.Request) error { 49 | lp.lock.Lock() 50 | defer lp.lock.Unlock() 51 | 52 | conn, err := lp.upgrader.Upgrade(w, r, nil) 53 | if err != nil { 54 | return fmt.Errorf("Failed to upgrade HTTP connection to a websocket: %w", err) 55 | } 56 | 57 | rtmp := rtmp.NewRTMPClient(lp.publishURL, streamKey) 58 | err = rtmp.StartBroadcast() 59 | if err != nil { 60 | return fmt.Errorf("Failed to start RTMP broadcast: %w", err) 61 | } 62 | 63 | newStreamer := newLivestreamConnection(sessionID, userID, streamKey, conn) 64 | 65 | livestream := &livestream{ 66 | Streamer: newStreamer, 67 | RTMPClient: rtmp, 68 | } 69 | 70 | if _, exists := lp.connections[sessionID]; !exists { 71 | lp.connections[sessionID] = livestream 72 | } else { 73 | lp.connections[sessionID].RTMPClient.StopBroadcast() 74 | lp.connections[sessionID].Streamer.Websocket.Close() 75 | lp.connections[sessionID] = livestream 76 | } 77 | 78 | go func() { 79 | for { 80 | messageType, payload, err := newStreamer.Websocket.ReadMessage() 81 | if err != nil || messageType == websocket.CloseMessage { 82 | lp.lock.Lock() 83 | defer lp.lock.Unlock() 84 | 85 | rtmp.StopBroadcast() 86 | newStreamer.Websocket.Close() 87 | if existingLivestream, exists := lp.connections[sessionID]; exists { 88 | if existingLivestream.Streamer.ID == newStreamer.ID { 89 | delete(lp.connections, sessionID) 90 | } 91 | } 92 | break 93 | } 94 | 95 | rtmp.PipeToBroadcast(&payload) 96 | } 97 | }() 98 | 99 | return nil 100 | } 101 | 102 | // RemoveStreamer ... 103 | func (lp *LivestreamPool) RemoveStreamer(sessionID string) error { 104 | lp.lock.Lock() 105 | defer lp.lock.Unlock() 106 | 107 | if existingLivestream, exists := lp.connections[sessionID]; exists { 108 | existingLivestream.RTMPClient.StopBroadcast() 109 | existingLivestream.Streamer.Websocket.Close() 110 | delete(lp.connections, sessionID) 111 | } else { 112 | return ErrSessionDoesNotExist 113 | } 114 | 115 | return nil 116 | } 117 | 118 | // StreamerIsRegisteredForSession ... 119 | func (lp *LivestreamPool) StreamerIsRegisteredForSession(sessionID string) bool { 120 | lp.lock.Lock() 121 | defer lp.lock.Unlock() 122 | 123 | _, exists := lp.connections[sessionID] 124 | 125 | return exists 126 | } 127 | -------------------------------------------------------------------------------- /static/vendor/axios.min.js: -------------------------------------------------------------------------------- 1 | /* axios v0.19.2 | (c) 2020 by Matt Zabriskie */ 2 | !function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.axios=t():e.axios=t()}(this,function(){return function(e){function t(r){if(n[r])return n[r].exports;var o=n[r]={exports:{},id:r,loaded:!1};return e[r].call(o.exports,o,o.exports,t),o.loaded=!0,o.exports}var n={};return t.m=e,t.c=n,t.p="",t(0)}([function(e,t,n){e.exports=n(1)},function(e,t,n){"use strict";function r(e){var t=new s(e),n=i(s.prototype.request,t);return o.extend(n,s.prototype,t),o.extend(n,t),n}var o=n(2),i=n(3),s=n(4),a=n(22),u=n(10),c=r(u);c.Axios=s,c.create=function(e){return r(a(c.defaults,e))},c.Cancel=n(23),c.CancelToken=n(24),c.isCancel=n(9),c.all=function(e){return Promise.all(e)},c.spread=n(25),e.exports=c,e.exports.default=c},function(e,t,n){"use strict";function r(e){return"[object Array]"===j.call(e)}function o(e){return"undefined"==typeof e}function i(e){return null!==e&&!o(e)&&null!==e.constructor&&!o(e.constructor)&&"function"==typeof e.constructor.isBuffer&&e.constructor.isBuffer(e)}function s(e){return"[object ArrayBuffer]"===j.call(e)}function a(e){return"undefined"!=typeof FormData&&e instanceof FormData}function u(e){var t;return t="undefined"!=typeof ArrayBuffer&&ArrayBuffer.isView?ArrayBuffer.isView(e):e&&e.buffer&&e.buffer instanceof ArrayBuffer}function c(e){return"string"==typeof e}function f(e){return"number"==typeof e}function p(e){return null!==e&&"object"==typeof e}function d(e){return"[object Date]"===j.call(e)}function l(e){return"[object File]"===j.call(e)}function h(e){return"[object Blob]"===j.call(e)}function m(e){return"[object Function]"===j.call(e)}function y(e){return p(e)&&m(e.pipe)}function g(e){return"undefined"!=typeof URLSearchParams&&e instanceof URLSearchParams}function v(e){return e.replace(/^\s*/,"").replace(/\s*$/,"")}function x(){return("undefined"==typeof navigator||"ReactNative"!==navigator.product&&"NativeScript"!==navigator.product&&"NS"!==navigator.product)&&("undefined"!=typeof window&&"undefined"!=typeof document)}function w(e,t){if(null!==e&&"undefined"!=typeof e)if("object"!=typeof e&&(e=[e]),r(e))for(var n=0,o=e.length;n=200&&e<300}};u.headers={common:{Accept:"application/json, text/plain, */*"}},i.forEach(["delete","get","head"],function(e){u.headers[e]={}}),i.forEach(["post","put","patch"],function(e){u.headers[e]=i.merge(a)}),e.exports=u},function(e,t,n){"use strict";var r=n(2);e.exports=function(e,t){r.forEach(e,function(n,r){r!==t&&r.toUpperCase()===t.toUpperCase()&&(e[t]=n,delete e[r])})}},function(e,t,n){"use strict";var r=n(2),o=n(13),i=n(5),s=n(16),a=n(19),u=n(20),c=n(14);e.exports=function(e){return new Promise(function(t,f){var p=e.data,d=e.headers;r.isFormData(p)&&delete d["Content-Type"];var l=new XMLHttpRequest;if(e.auth){var h=e.auth.username||"",m=e.auth.password||"";d.Authorization="Basic "+btoa(h+":"+m)}var y=s(e.baseURL,e.url);if(l.open(e.method.toUpperCase(),i(y,e.params,e.paramsSerializer),!0),l.timeout=e.timeout,l.onreadystatechange=function(){if(l&&4===l.readyState&&(0!==l.status||l.responseURL&&0===l.responseURL.indexOf("file:"))){var n="getAllResponseHeaders"in l?a(l.getAllResponseHeaders()):null,r=e.responseType&&"text"!==e.responseType?l.response:l.responseText,i={data:r,status:l.status,statusText:l.statusText,headers:n,config:e,request:l};o(t,f,i),l=null}},l.onabort=function(){l&&(f(c("Request aborted",e,"ECONNABORTED",l)),l=null)},l.onerror=function(){f(c("Network Error",e,null,l)),l=null},l.ontimeout=function(){var t="timeout of "+e.timeout+"ms exceeded";e.timeoutErrorMessage&&(t=e.timeoutErrorMessage),f(c(t,e,"ECONNABORTED",l)),l=null},r.isStandardBrowserEnv()){var g=n(21),v=(e.withCredentials||u(y))&&e.xsrfCookieName?g.read(e.xsrfCookieName):void 0;v&&(d[e.xsrfHeaderName]=v)}if("setRequestHeader"in l&&r.forEach(d,function(e,t){"undefined"==typeof p&&"content-type"===t.toLowerCase()?delete d[t]:l.setRequestHeader(t,e)}),r.isUndefined(e.withCredentials)||(l.withCredentials=!!e.withCredentials),e.responseType)try{l.responseType=e.responseType}catch(t){if("json"!==e.responseType)throw t}"function"==typeof e.onDownloadProgress&&l.addEventListener("progress",e.onDownloadProgress),"function"==typeof e.onUploadProgress&&l.upload&&l.upload.addEventListener("progress",e.onUploadProgress),e.cancelToken&&e.cancelToken.promise.then(function(e){l&&(l.abort(),f(e),l=null)}),void 0===p&&(p=null),l.send(p)})}},function(e,t,n){"use strict";var r=n(14);e.exports=function(e,t,n){var o=n.config.validateStatus;!o||o(n.status)?e(n):t(r("Request failed with status code "+n.status,n.config,null,n.request,n))}},function(e,t,n){"use strict";var r=n(15);e.exports=function(e,t,n,o,i){var s=new Error(e);return r(s,t,n,o,i)}},function(e,t){"use strict";e.exports=function(e,t,n,r,o){return e.config=t,n&&(e.code=n),e.request=r,e.response=o,e.isAxiosError=!0,e.toJSON=function(){return{message:this.message,name:this.name,description:this.description,number:this.number,fileName:this.fileName,lineNumber:this.lineNumber,columnNumber:this.columnNumber,stack:this.stack,config:this.config,code:this.code}},e}},function(e,t,n){"use strict";var r=n(17),o=n(18);e.exports=function(e,t){return e&&!r(t)?o(e,t):t}},function(e,t){"use strict";e.exports=function(e){return/^([a-z][a-z\d\+\-\.]*:)?\/\//i.test(e)}},function(e,t){"use strict";e.exports=function(e,t){return t?e.replace(/\/+$/,"")+"/"+t.replace(/^\/+/,""):e}},function(e,t,n){"use strict";var r=n(2),o=["age","authorization","content-length","content-type","etag","expires","from","host","if-modified-since","if-unmodified-since","last-modified","location","max-forwards","proxy-authorization","referer","retry-after","user-agent"];e.exports=function(e){var t,n,i,s={};return e?(r.forEach(e.split("\n"),function(e){if(i=e.indexOf(":"),t=r.trim(e.substr(0,i)).toLowerCase(),n=r.trim(e.substr(i+1)),t){if(s[t]&&o.indexOf(t)>=0)return;"set-cookie"===t?s[t]=(s[t]?s[t]:[]).concat([n]):s[t]=s[t]?s[t]+", "+n:n}}),s):s}},function(e,t,n){"use strict";var r=n(2);e.exports=r.isStandardBrowserEnv()?function(){function e(e){var t=e;return n&&(o.setAttribute("href",t),t=o.href),o.setAttribute("href",t),{href:o.href,protocol:o.protocol?o.protocol.replace(/:$/,""):"",host:o.host,search:o.search?o.search.replace(/^\?/,""):"",hash:o.hash?o.hash.replace(/^#/,""):"",hostname:o.hostname,port:o.port,pathname:"/"===o.pathname.charAt(0)?o.pathname:"/"+o.pathname}}var t,n=/(msie|trident)/i.test(navigator.userAgent),o=document.createElement("a");return t=e(window.location.href),function(n){var o=r.isString(n)?e(n):n;return o.protocol===t.protocol&&o.host===t.host}}():function(){return function(){return!0}}()},function(e,t,n){"use strict";var r=n(2);e.exports=r.isStandardBrowserEnv()?function(){return{write:function(e,t,n,o,i,s){var a=[];a.push(e+"="+encodeURIComponent(t)),r.isNumber(n)&&a.push("expires="+new Date(n).toGMTString()),r.isString(o)&&a.push("path="+o),r.isString(i)&&a.push("domain="+i),s===!0&&a.push("secure"),document.cookie=a.join("; ")},read:function(e){var t=document.cookie.match(new RegExp("(^|;\\s*)("+e+")=([^;]*)"));return t?decodeURIComponent(t[3]):null},remove:function(e){this.write(e,"",Date.now()-864e5)}}}():function(){return{write:function(){},read:function(){return null},remove:function(){}}}()},function(e,t,n){"use strict";var r=n(2);e.exports=function(e,t){t=t||{};var n={},o=["url","method","params","data"],i=["headers","auth","proxy"],s=["baseURL","url","transformRequest","transformResponse","paramsSerializer","timeout","withCredentials","adapter","responseType","xsrfCookieName","xsrfHeaderName","onUploadProgress","onDownloadProgress","maxContentLength","validateStatus","maxRedirects","httpAgent","httpsAgent","cancelToken","socketPath"];r.forEach(o,function(e){"undefined"!=typeof t[e]&&(n[e]=t[e])}),r.forEach(i,function(o){r.isObject(t[o])?n[o]=r.deepMerge(e[o],t[o]):"undefined"!=typeof t[o]?n[o]=t[o]:r.isObject(e[o])?n[o]=r.deepMerge(e[o]):"undefined"!=typeof e[o]&&(n[o]=e[o])}),r.forEach(s,function(r){"undefined"!=typeof t[r]?n[r]=t[r]:"undefined"!=typeof e[r]&&(n[r]=e[r])});var a=o.concat(i).concat(s),u=Object.keys(t).filter(function(e){return a.indexOf(e)===-1});return r.forEach(u,function(r){"undefined"!=typeof t[r]?n[r]=t[r]:"undefined"!=typeof e[r]&&(n[r]=e[r])}),n}},function(e,t){"use strict";function n(e){this.message=e}n.prototype.toString=function(){return"Cancel"+(this.message?": "+this.message:"")},n.prototype.__CANCEL__=!0,e.exports=n},function(e,t,n){"use strict";function r(e){if("function"!=typeof e)throw new TypeError("executor must be a function.");var t;this.promise=new Promise(function(e){t=e});var n=this;e(function(e){n.reason||(n.reason=new o(e),t(n.reason))})}var o=n(23);r.prototype.throwIfRequested=function(){if(this.reason)throw this.reason},r.source=function(){var e,t=new r(function(t){e=t});return{token:t,cancel:e}},e.exports=r},function(e,t){"use strict";e.exports=function(e){return function(t){return e.apply(null,t)}}}])}); 3 | //# sourceMappingURL=axios.min.map --------------------------------------------------------------------------------