├── app
├── __init__.py
├── resources
│ ├── feedlost.jpeg
│ └── dummy.header
├── __pycache__
│ ├── status.cpython-36.pyc
│ ├── status.cpython-37.pyc
│ ├── __init__.cpython-36.pyc
│ ├── __init__.cpython-37.pyc
│ ├── streaming.cpython-36.pyc
│ ├── streaming.cpython-37.pyc
│ ├── broadcaster.cpython-36.pyc
│ ├── broadcaster.cpython-37.pyc
│ ├── httprequesthandler.cpython-36.pyc
│ └── httprequesthandler.cpython-37.pyc
├── web
│ ├── style.css
│ └── status.html
├── status.py
├── streaming.py
├── httprequesthandler.py
└── broadcaster.py
├── .dockerignore
├── requirements.txt
├── Dockerfile
├── Jenkinsfile
├── websocketexample.html
├── LICENSE
├── relay.py
└── README.md
/app/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .git
2 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | requests
2 | SimpleWebSocketServer
--------------------------------------------------------------------------------
/app/resources/feedlost.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/101/mjpeg-relay/master/app/resources/feedlost.jpeg
--------------------------------------------------------------------------------
/app/__pycache__/status.cpython-36.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/101/mjpeg-relay/master/app/__pycache__/status.cpython-36.pyc
--------------------------------------------------------------------------------
/app/__pycache__/status.cpython-37.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/101/mjpeg-relay/master/app/__pycache__/status.cpython-37.pyc
--------------------------------------------------------------------------------
/app/__pycache__/__init__.cpython-36.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/101/mjpeg-relay/master/app/__pycache__/__init__.cpython-36.pyc
--------------------------------------------------------------------------------
/app/__pycache__/__init__.cpython-37.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/101/mjpeg-relay/master/app/__pycache__/__init__.cpython-37.pyc
--------------------------------------------------------------------------------
/app/__pycache__/streaming.cpython-36.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/101/mjpeg-relay/master/app/__pycache__/streaming.cpython-36.pyc
--------------------------------------------------------------------------------
/app/__pycache__/streaming.cpython-37.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/101/mjpeg-relay/master/app/__pycache__/streaming.cpython-37.pyc
--------------------------------------------------------------------------------
/app/__pycache__/broadcaster.cpython-36.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/101/mjpeg-relay/master/app/__pycache__/broadcaster.cpython-36.pyc
--------------------------------------------------------------------------------
/app/__pycache__/broadcaster.cpython-37.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/101/mjpeg-relay/master/app/__pycache__/broadcaster.cpython-37.pyc
--------------------------------------------------------------------------------
/app/__pycache__/httprequesthandler.cpython-36.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/101/mjpeg-relay/master/app/__pycache__/httprequesthandler.cpython-36.pyc
--------------------------------------------------------------------------------
/app/__pycache__/httprequesthandler.cpython-37.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/101/mjpeg-relay/master/app/__pycache__/httprequesthandler.cpython-37.pyc
--------------------------------------------------------------------------------
/app/web/style.css:
--------------------------------------------------------------------------------
1 | table, th, td {
2 | border: 1px solid black;
3 | padding: 5px;
4 | }
5 |
6 | table {
7 | border-collapse: collapse;
8 | }
9 |
10 | thead {
11 | font-weight: bold;
12 | }
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:alpine
2 |
3 | ENV PYTHONUNBUFFERED=1
4 | ENV SOURCE_URL="http://localhost:8081/?action=stream"
5 |
6 | COPY . /
7 | RUN pip3 install -r /requirements.txt
8 |
9 | EXPOSE 54321
10 | EXPOSE 54322
11 | CMD ["python", "/relay.py"]
12 |
--------------------------------------------------------------------------------
/app/resources/dummy.header:
--------------------------------------------------------------------------------
1 | HTTP/1.0 200 OK
2 | Connection: close
3 | Server: MJPEG-Relay
4 | Cache-Control: no-store, no-cache, must-revalidate, pre-check=0, post-check=0, max-age=0
5 | Pragma: no-cache
6 | Expires: -1
7 | Content-Type: multipart/x-mixed-replace;boundary={boundaryKey}
8 |
9 |
--------------------------------------------------------------------------------
/app/web/status.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | mjpeg-relay
4 |
5 |
6 |
7 |
8 | mjpeg-relay status
9 | Summary
10 |
11 |
12 | | Parameter | Value |
13 |
14 | | Clients | {clientcount} |
15 |
16 |
17 | | Incoming bandwidth | {bwin:.4f}Mb/s |
18 |
19 |
20 | | Outgoing bandwidth | {bwout:.4f}Mb/s |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/app/status.py:
--------------------------------------------------------------------------------
1 | import time
2 |
3 | class Status:
4 |
5 | _instance = None
6 |
7 | def __init__(self):
8 | self.bytesOut = 0
9 | self.bytesIn = 0
10 |
11 | self.bandwidthOut = 0
12 | self.bandwidthIn = 0
13 |
14 | Status._instance = self
15 |
16 | def addToBytesOut(self, byteCount):
17 | self.bytesOut += byteCount
18 |
19 | def addToBytesIn(self, byteCount):
20 | self.bytesIn += byteCount
21 |
22 | def run(self):
23 | while True:
24 | self.bandwidthOut = self.bytesOut
25 | self.bandwidthIn = self.bytesIn
26 |
27 | self.bytesIn = 0
28 | self.bytesOut = 0
29 |
30 | time.sleep(1)
31 |
--------------------------------------------------------------------------------
/Jenkinsfile:
--------------------------------------------------------------------------------
1 | pipeline{
2 |
3 | agent any
4 |
5 | environment {
6 | REGISTRY="hdavid0510/mjpeg-relay"
7 | REGISTRY_CREDENTIALS='dockerhub-credential'
8 | //TAG=":$BUILD_NUMBER"
9 | TAG="latest"
10 | DOCKERIMAGE=''
11 | }
12 |
13 | stages {
14 |
15 | stage('Build') {
16 | steps {
17 | script {
18 | DOCKERIMAGE = docker.build REGISTRY + ":" + TAG
19 | }
20 | }
21 | }
22 |
23 | stage('Push') {
24 | steps {
25 | script {
26 | docker.withRegistry( '', REGISTRY_CREDENTIALS ){
27 | DOCKERIMAGE.push()
28 | }
29 | }
30 | }
31 | }
32 | }
33 |
34 | post {
35 | always {
36 | sh "docker rmi $REGISTRY:$TAG"
37 | }
38 | }
39 |
40 | }
41 |
--------------------------------------------------------------------------------
/websocketexample.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | mjpeg-relay example
4 |
5 |
6 |
7 |
8 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2014
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.
22 |
23 |
--------------------------------------------------------------------------------
/app/streaming.py:
--------------------------------------------------------------------------------
1 | import queue
2 | import socket
3 | import threading
4 | from .broadcaster import Broadcaster
5 |
6 | # try:
7 | # from .SimpleWebSocketServer.SimpleWebSocketServer import WebSocket
8 | # except ImportError as e:
9 | # print(("Failed to import dependency: {}".format(e)))
10 | # print("Please ensure the SimpleWebSocketServer submodule has been correctly installed: git submodule update --init")
11 | # sys.exit(1)
12 | from SimpleWebSocketServer import WebSocket
13 |
14 | class StreamingClient(object):
15 |
16 | def __init__(self):
17 | self.streamBuffer: bytes = b""
18 | self.streamQueue = queue.Queue()
19 | self.streamThread = threading.Thread(target = self.stream)
20 | self.streamThread.daemon = True
21 | self.connected = True
22 | self.kill = False
23 | super(StreamingClient, self).__init__()
24 |
25 | def start(self):
26 | self.streamThread.start()
27 |
28 | def transmit(self, data):
29 | return len(data)
30 |
31 | def stop(self):
32 | pass
33 |
34 | def bufferStreamData(self, data):
35 | #use a thread-safe queue to ensure stream buffer is not modified while we're sending it
36 | self.streamQueue.put(data)
37 |
38 | def stream(self):
39 | while self.connected:
40 | #this call blocks if there's no data in the queue, avoiding the need for busy-waiting
41 | self.streamBuffer += self.streamQueue.get()
42 |
43 | #check if kill or connected state has changed after being blocked
44 | if (self.kill or not self.connected):
45 | self.stop()
46 | return
47 |
48 | while (len(self.streamBuffer) > 0):
49 | streamedTo = self.transmit(self.streamBuffer)
50 | if (streamedTo and streamedTo >= 0):
51 | self.streamBuffer = self.streamBuffer[streamedTo:]
52 | else:
53 | self.streamBuffer = b""
54 |
55 | class TCPStreamingClient(StreamingClient):
56 | def __init__(self, sock):
57 | super(TCPStreamingClient, self).__init__()
58 | self.sock = sock
59 | self.sock.settimeout(5)
60 |
61 | def stop(self):
62 | self.sock.close()
63 |
64 | def transmit(self, data):
65 | try:
66 | return self.sock.send(data)
67 | except socket.error as e:
68 | self.connected = False
69 | self.sock.close()
70 |
71 | class WebSocketStreamingClient(WebSocket, StreamingClient):
72 | def __init__(self, *args, **kwargs):
73 | super(WebSocketStreamingClient, self).__init__(*args, **kwargs)
74 |
75 | def stop(self):
76 | pass
77 |
78 | def transmit(self, data):
79 | self.sendMessage("data:image/jpg;base64," + data)
80 | return len(data)
81 |
82 | def handleConnected(self):
83 | self.start()
84 | Broadcaster._instance.webSocketClients.append(self)
85 |
86 | def handleClose(self):
87 | self.connected = False
88 |
--------------------------------------------------------------------------------
/relay.py:
--------------------------------------------------------------------------------
1 | import sys
2 | sys.tracebacklimit = 0
3 | import socket
4 | import threading
5 | import os
6 | import logging
7 | from SimpleWebSocketServer import SimpleWebSocketServer
8 | from optparse import OptionParser
9 | from app.status import Status
10 | from app.broadcaster import Broadcaster
11 | from app.httprequesthandler import HTTPRequestHandler
12 | from app.streaming import WebSocketStreamingClient
13 |
14 |
15 | #
16 | # Close threads gracefully
17 | #
18 | def quit():
19 | broadcaster.kill = True
20 | requestHandler.kill = True
21 | quitsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
22 | quitsock.connect(("127.0.0.1", options.port))
23 | quitsock.close()
24 | sys.exit(1)
25 |
26 | if __name__ == '__main__':
27 | op = OptionParser(usage = "%prog [options] stream-source-url")
28 |
29 | op.add_option("-p", "--port", action="store", default = 54321, dest="port", help = "Port to serve the MJPEG stream on")
30 | op.add_option("-w", "--ws-port", action="store", default = 54322, dest="wsport", help = "Port to serve the MJPEG stream on via WebSockets")
31 | op.add_option("-q", "--quiet", action="store_true", default = False, dest="quiet", help = "Silence non-essential output")
32 | op.add_option("-d", "--debug", action="store_true", default = False, dest="debug", help = "Turn debugging on")
33 |
34 | (options, args) = op.parse_args()
35 |
36 | if (len(args) != 1):
37 | logging.info(f"ENV SOURCE_URL = {os.environ.get('SOURCE_URL', None)}")
38 | if os.environ.get('SOURCE_URL', None) == None:
39 | op.print_help()
40 | sys.exit(1)
41 | else:
42 | source = os.environ.get('SOURCE_URL', None)
43 | else:
44 | source = args[0]
45 |
46 | logging.basicConfig(level=logging.WARNING if options.quiet else logging.INFO, format="%(message)s")
47 | logging.getLogger("requests").setLevel(logging.WARNING if options.quiet else logging.INFO)
48 |
49 | if options.debug:
50 | from http.client import HTTPConnection
51 | HTTPConnection.debuglevel = 1
52 | logging.getLogger().setLevel(logging.DEBUG)
53 | logging.getLogger("requests").setLevel(logging.DEBUG)
54 |
55 | try:
56 | options.port = int(options.port)
57 | options.wsport = int(options.wsport)
58 | except ValueError:
59 | logging.error("Port must be numeric")
60 | op.print_help()
61 | sys.exit(1)
62 |
63 | Status()
64 | statusThread = threading.Thread(target=Status._instance.run)
65 | statusThread.daemon = True
66 | statusThread.start()
67 |
68 | broadcaster = Broadcaster(source)
69 | broadcaster.start()
70 |
71 | requestHandler = HTTPRequestHandler(options.port)
72 | requestHandler.start()
73 |
74 | s = SimpleWebSocketServer('', options.wsport, WebSocketStreamingClient)
75 | webSocketHandlerThread = threading.Thread(target=s.serveforever)
76 | webSocketHandlerThread.daemon = True
77 | webSocketHandlerThread.start()
78 |
79 | try:
80 | while eval(input()) != "quit":
81 | continue
82 | quit()
83 | except KeyboardInterrupt:
84 | quit()
85 | except EOFError:
86 | #this exception is raised when ctrl-c is used to close the application on Windows, appears to be thrown twice?
87 | try:
88 | quit()
89 | except KeyboardInterrupt:
90 | os._exit(0)
91 |
--------------------------------------------------------------------------------
/app/httprequesthandler.py:
--------------------------------------------------------------------------------
1 | import socket
2 | import threading
3 | import logging
4 | import re
5 | import traceback
6 | from .status import Status
7 | from .streaming import TCPStreamingClient
8 | from .broadcaster import Broadcaster
9 |
10 | class HTTPRequestHandler:
11 | """Handles the initial connection with HTTP clients"""
12 |
13 | def __init__(self, port):
14 | #response we'll send to the client, pretending to be from the real stream source
15 | dummyHeaderfh = open('app/resources/dummy.header', 'r')
16 | self.dummyHeader = dummyHeaderfh.read()
17 |
18 | cssfh = open('app/web/style.css', 'r')
19 | self.statusCSS = cssfh.read()
20 |
21 | htmlfh = open('app/web/status.html', 'r')
22 | self.statusHTML = htmlfh.read()
23 |
24 | self.acceptsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
25 | self.acceptsock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
26 | self.acceptsock.bind(("0.0.0.0", port))
27 | self.acceptsock.listen(10)
28 |
29 | self.broadcast = Broadcaster._instance
30 | self.status = Status._instance
31 |
32 | self.kill = False
33 |
34 | self.acceptThread = threading.Thread(target = self.acceptClients)
35 | self.acceptThread.daemon = True
36 |
37 | def start(self):
38 | self.acceptThread.start()
39 |
40 | #
41 | # Thread to process client requests
42 | #
43 | def handleRequest(self, clientsock):
44 | buff = bytearray()
45 | while True:
46 | try:
47 | data = clientsock.recv(64)
48 | if (data == b""):
49 | break
50 |
51 | buff.extend(data)
52 |
53 | if buff.find(b"\r\n\r\n") >= 0 or buff.find(b"\n\n") >= 0:
54 | break #as soon as the header is sent - we only care about GET requests
55 |
56 | except Exception as e:
57 | logging.error(f"Error on importing request data to buffer")
58 | traceback.print_exc()
59 | break
60 |
61 | if (buff != b""):
62 | try:
63 | match = re.search(b'GET (.*) ', buff)
64 | requestPath = match.group(1)
65 | except Exception as e:
66 | logging.error("Client sent unexpected request")
67 | logging.debug(f"Request: {buff}")
68 | return
69 |
70 | #explicitly deal with individual requests. Verbose, but more secure
71 | if (b"/status" in requestPath):
72 | clientsock.sendall(b'HTTP/1.0 200 OK\r\nContentType: text/html\r\n\r\n')
73 | clientsock.sendall(self.statusHTML.format(clientcount = self.broadcast.getClientCount(), bwin = float(self.status.bandwidthIn*8)/1000000, bwout = float(self.status.bandwidthOut*8)/1000000).encode())
74 | clientsock.close()
75 | elif (b"/style.css" in requestPath):
76 | clientsock.sendall(b'HTTP/1.0 200 OK\r\nContentType: text/html\r\n\r\n')
77 | clientsock.sendall(self.statusCSS.encode())
78 | clientsock.close()
79 | elif (b"/stream" in requestPath):
80 | if (self.broadcast.broadcasting):
81 | clientsock.sendall(self.dummyHeader.format(boundaryKey = self.broadcast.boundarySeparator.decode()).encode())
82 | client = TCPStreamingClient(clientsock)
83 | client.start()
84 | self.broadcast.clients.append(client)
85 | else:
86 | clientsock.close()
87 | elif (b"/snapshot" in requestPath):
88 | clientsock.sendall(b'HTTP/1.0 200 OK\r\n')
89 | clientsock.sendall(b'Content-Type: image/jpeg\r\n')
90 | clientsock.sendall(f'Content-Length: {len(self.broadcast.lastFrame)}\r\n\r\n'.encode())
91 | clientsock.sendall(self.broadcast.lastFrame)
92 | clientsock.close()
93 | else:
94 | clientsock.sendall(b'HTTP/1.0 302 FOUND\r\nLocation: /status')
95 | clientsock.close()
96 | else:
97 | logging.info("Client connected but didn't make a request")
98 |
99 | #
100 | # Thread to handle connecting clients
101 | #
102 | def acceptClients(self):
103 | while True:
104 | clientsock, addr = self.acceptsock.accept()
105 |
106 | if self.kill:
107 | clientsock.close()
108 | return
109 | handlethread = threading.Thread(target = self.handleRequest, args = (clientsock,))
110 | handlethread.start()
111 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # mjpeg-relay
2 |
3 | mjpeg-relay is a simple Python script which accepts input from an existing MJPEG stream, and relays it to any number of clients. This is intended for a scenario where the original MJPEG stream is hosted on a low-bandwidth internet connection (such as a home connection) or from a low-resource device (such as a Raspberry Pi or IP camera), and you have a server from which you wish to relay the stream to multiple clients without placing extra demand on the original stream.
4 |
5 | The script is designed to be simple to use with minimal configuration. All video parameters are defined by the source stream, ensuring mjpeg-relay is as transparent as possible. Rather than creating its own MJPEG stream, mjpeg-relay simply re-streams the original MJPEG stream directly. This is a faster and more transparent approach.
6 |
7 | **Python 3.6+ is required.**
8 |
9 |
10 | ## Features
11 | - Low resource
12 | - Low latency
13 | - Status page
14 | - Option to stream to clients via WebSockets
15 |
16 |
17 | ## Installation
18 | 1. Clone this repository with `git clone `
19 | 2. Ensure dependencies are correctly installed by running `pip install -r requirements.txt`
20 |
21 |
22 | ## Usage
23 | `relay.py [-p ] [-w ] [-q] [-d] stream-source-url`
24 |
25 | - **-p \**: Port that the stream will be relayed on (default is 54321)
26 | - **-w \**: Port that the stream will be relayed on via WebSockets (default is 54322)
27 | - **-q**: Silence non-essential output
28 | - **-d**: Turn debugging on
29 | - **stream-source-url**: URL of the existing MJPEG stream. If the stream is protected with HTTP authentication, supply the credentials via the URL like so: `http://user:password@ip:port/path/to/stream/`
30 |
31 | Once it is running, you can access the following URLs:
32 |
33 | * `/status`: the status summary of mjpeg-relay
34 | * `/stream`: the MJPEG stream. This can be embedded directly into an `
` tag on modern browsers like so: `
`
35 | * `/snapshot`: the latest JPEG frame from the MJPEG stream
36 |
37 |
38 | ## Example
39 |
40 | **Situation:** Relaying MJPEG stream at 192.0.2.1:1234/?action=stream on port 54017
41 |
42 | 1. Start the relay: `python relay.py -p 54017 "http://192.0.2.1:1234/?action=stream"`
43 | 2. Confirm that mjpeg-relay has connected to the remote stream
44 | 3. Connect to the relayed stream at `http://localhost:54017/stream`. This can be embedded directly into an `
` tag on modern browsers like so: `
`
45 | 4. The status of mjpeg-relay is displayed at `http://localhost:54017/status`
46 |
47 |
48 | ## WebSocket Example
49 |
50 | **As above, but also relaying the MJPEG stream via WebSockets on port 54018**
51 |
52 | 1. Start the relay: `python relay.py -p 54017 -w 54018 "http://192.0.2.1:1234/?action=stream"`
53 | 2. Confirm that mjpeg-relay has connected to the remote stream
54 | 3. Copy and paste the example HTML and JavaScript in the file `websocketexample.html` into your website, and adapt as necessary
55 | 4. The status of mjpeg-relay is displayed at `http://localhost:54017/status`
56 |
57 |
58 |
59 | # mjpeg-relay Docker image
60 |
61 | [](https://hub.docker.com/r/hdavid0510/mjpeg-relay) [](https://github.com/hdavid0510/mjpeg-relay/issues)
62 | Docker image which all the scripts in this repository is preinstalled on [python:alpine](https://hub.docker.com/r/_/python)
63 |
64 |
65 | ## Tags
66 |
67 | ### latest
68 | []() []()
69 | Built from `master` branch
70 |
71 |
72 | ## Environment Variables
73 |
74 | ### `SOURCE_URL`
75 | * URL of the source of the stream, in form of `http://user:password@ip:port/path/to/stream/`.
76 | * **DEFAULT** `http://localhost:8081/?action=stream`
77 |
78 |
79 | ## Port Bindings
80 | | Option | Port# | Type | Service |
81 | | ------ | ----- | ---- | ------- |
82 | |__Required__|54321|tcp| Relayed stream is shown through this port. |
83 | |_Optional_|54322|tcp| Relayed stream **via WebSocket** is shown through this port.|
84 |
85 |
86 | ## Build
87 |
88 | ``` shell
89 | docker build -t IMAGE_NAME .
90 | docker run -it -p 54017:54321 -e "http://192.0.2.1:1234/?action=stream" IMAGE_NAME
91 | ```
92 |
--------------------------------------------------------------------------------
/app/broadcaster.py:
--------------------------------------------------------------------------------
1 | import sys
2 | sys.tracebacklimit = 0
3 | import traceback
4 | import threading
5 | import logging
6 | import requests
7 | import re
8 | import time
9 | import base64
10 | from .status import Status
11 |
12 | class HTTPBasicThenDigestAuth(requests.auth.HTTPDigestAuth):
13 | """Try HTTPBasicAuth, then HTTPDigestAuth."""
14 |
15 | def __init__(self):
16 | super(HTTPBasicThenDigestAuth, self).__init__(None, None)
17 |
18 | def __call__(self, r):
19 | # Extract auth from URL
20 | self.username, self.password = requests.utils.get_auth_from_url(r.url)
21 |
22 | # Prepare basic auth
23 | r = requests.auth.HTTPBasicAuth(self.username, self.password).__call__(r)
24 |
25 | # Let HTTPDigestAuth handle the 401
26 | return super(HTTPBasicThenDigestAuth, self).__call__(r)
27 |
28 | class Broadcaster:
29 | """Handles relaying the source MJPEG stream to connected clients"""
30 |
31 | _instance = None
32 |
33 | def __init__(self, url):
34 | self.url = url
35 |
36 | self.clients = []
37 | self.webSocketClients = []
38 |
39 | self.status = Status._instance
40 |
41 | self.kill = False
42 | self.broadcastThread = threading.Thread(target = self.streamFromSource)
43 | self.broadcastThread.daemon = True
44 |
45 | self.lastFrame: bytes = b""
46 | self.lastFrameBuffer: bytes = b""
47 |
48 | self.connected = False
49 | self.broadcasting = False
50 |
51 | try:
52 | feedLostFile = open("app/resources/feedlost.jpeg", "rb") #read-only, binary
53 | feedLostImage = feedLostFile.read()
54 | feedLostFile.close()
55 | self.feedLostFrame = bytearray(f"Content-Type: image/jpeg\r\nContent-Length: {len(feedLostImage)}\r\n\r\n",'utf-8')
56 | self.feedLostFrame.extend(feedLostImage)
57 | except IOError as e:
58 | logging.warning("Unable to read feedlost.jpeg")
59 | # traceback.print_exc()
60 | self.feedLostFrame = False
61 |
62 | Broadcaster._instance = self
63 |
64 | def start(self):
65 | if (self.connectToStream()):
66 | self.broadcasting = True
67 | logging.info(f"Connected to stream source, boundary separator: {self.boundarySeparator}")
68 | self.broadcastThread.start()
69 |
70 | #
71 | # Connects to the stream source
72 | #
73 | def connectToStream(self):
74 | try:
75 | self.sourceStream = requests.get(self.url, stream = True, timeout = 3, auth = HTTPBasicThenDigestAuth())
76 | except Exception as e:
77 | logging.error(f"[ERROR] Unable to connect to stream source at {self.url}")
78 | traceback.print_exc()
79 | return False
80 | except:
81 | logging.error("[ERROR] failed to connect to stream source.")
82 | pass
83 |
84 |
85 | self.boundarySeparator: bytes = self.parseStreamHeader(self.sourceStream.headers['Content-Type']).encode()
86 |
87 | if (not self.boundarySeparator):
88 | logging.error("Unable to find boundary separator in the header returned from the stream source")
89 | return False
90 |
91 | self.connected = True
92 | return True
93 |
94 | #
95 | # Parses the stream header and returns the boundary separator
96 | #
97 | def parseStreamHeader(self, header) -> str:
98 | if (not isinstance(header, str)):
99 | return None
100 |
101 | match = re.search(r'boundary=(.*)', header, re.IGNORECASE)
102 | try:
103 | boundary = match.group(1)
104 | if not boundary.startswith("--"):
105 | boundary = "--" + boundary
106 | return boundary
107 | except:
108 | logging.error("[ERROR] Unexpected header returned from stream source; unable to parse boundary")
109 | logging.debug(f"header={header}")
110 | return None
111 |
112 | #
113 | # Returns the total number of connected clients
114 | #
115 | def getClientCount(self):
116 | return len(self.clients) + len(self.webSocketClients)
117 |
118 | #
119 | # Process data in frame buffer, extract frames when present
120 | #
121 | def extractFrames(self, frameBuffer: bytes):
122 | if (frameBuffer.count(self.boundarySeparator) >= 2):
123 | #calculate the start and end points of the frame
124 | start = frameBuffer.find(self.boundarySeparator)
125 | end = frameBuffer.find(self.boundarySeparator, start + 1)
126 |
127 | #extract full MJPEG frame
128 | mjpegFrame = frameBuffer[start:end]
129 |
130 | #extract frame data
131 | frameStart = frameBuffer.find(b"\r\n\r\n", start) + len(b"\r\n\r\n")
132 | frame = frameBuffer[frameStart:end]
133 |
134 | #process for WebSocket clients
135 | webSocketFrame = base64.b64encode(frame)
136 |
137 | return (mjpegFrame, webSocketFrame, frame, end)
138 | else:
139 | return (None, None, None, 0)
140 |
141 | #
142 | # Broadcast data to a list of StreamingClients
143 | #
144 | def broadcastToStreamingClients(self, clients, data: bytes):
145 | for client in clients:
146 | if (not client.connected):
147 | clients.remove(client)
148 | logging.info(f"\nClient left; {self.getClientCount()} client(s) connected")
149 | client.bufferStreamData(data)
150 |
151 | #
152 | # Broadcast data to all connected clients
153 | #
154 | def broadcast(self, data: bytes):
155 | self.lastFrameBuffer += data
156 |
157 | mjpegFrame, webSocketFrame, frame, bufferProcessedTo = self.extractFrames(self.lastFrameBuffer)
158 |
159 | if (mjpegFrame and webSocketFrame and frame):
160 | #delete the frame now that it has been extracted, keep what remains in the buffer
161 | self.lastFrameBuffer = self.lastFrameBuffer[bufferProcessedTo:]
162 |
163 | #save for /snapshot requests
164 | self.lastFrame = frame
165 |
166 | #serve to websocket clients
167 | self.broadcastToStreamingClients(self.webSocketClients, webSocketFrame)
168 |
169 | #serve to standard clients
170 | self.broadcastToStreamingClients(self.clients, mjpegFrame)
171 |
172 | #
173 | # Thread to handle reading the source of the stream and rebroadcasting
174 | #
175 | def streamFromSource(self):
176 | while True:
177 | try:
178 | for data in self.sourceStream.iter_content(1024):
179 | if self.kill:
180 | for client in self.clients + self.webSocketClients:
181 | client.kill = True
182 | return
183 | self.broadcast(data)
184 | self.status.addToBytesIn(len(data))
185 | self.status.addToBytesOut(len(data)*self.getClientCount())
186 | except Exception as e:
187 | logging.error(f"Lost connection to the stream source: \n")
188 | finally:
189 | #flush the frame buffer to avoid conflicting with future frame data
190 | self.lastFrameBuffer = b""
191 | self.connected = False
192 | while (not self.connected):
193 | data_during_lost = bytearray(self.boundarySeparator)
194 | data_during_lost.extend(b"\r\n")
195 | data_during_lost.extend(self.feedLostFrame)
196 | data_during_lost.extend(b"\r\n")
197 | if (self.feedLostFrame):
198 | self.broadcast(data_during_lost)
199 | time.sleep(5)
200 | self.connectToStream()
--------------------------------------------------------------------------------