├── 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
--------------------------------------------------------------------------------