├── 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 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
ParameterValue
Clients{clientcount}
Incoming bandwidth{bwin:.4f}Mb/s
Outgoing bandwidth{bwout:.4f}Mb/s
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://img.shields.io/docker/pulls/hdavid0510/mjpeg-relay?style=flat-square)](https://hub.docker.com/r/hdavid0510/mjpeg-relay) [![](https://img.shields.io/github/issues/hdavid0510/mjpeg-relay?style=flat-square)](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 | [![](https://img.shields.io/docker/v/hdavid0510/mjpeg-relay/latest?style=flat-square)]() [![](https://img.shields.io/docker/image-size/hdavid0510/mjpeg-relay/latest?style=flat-square)]() 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() --------------------------------------------------------------------------------