├── .gitignore ├── README.md ├── camera.py ├── export.py ├── index.py ├── shared.py └── web.py /.gitignore: -------------------------------------------------------------------------------- 1 | **/__pycache__/ 2 | data/* 3 | *.txt -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IP Camera Server 2 | 3 | Python and FFMPEG RTSP to HLS/MPEG-Dash web server 4 | 5 | ## Overview 6 | * RTSP stream URL as input 7 | * HLS and MPEG-Dash live output 8 | * Timelapse creation 9 | * Save output as JPEGs (frame) 10 | * Record short clips as both MP4 and Gif (moment) 11 | 12 | ## Installation 13 | ``` 14 | pip install ffmpeg-python 15 | pip install flask 16 | ``` 17 | 18 | ## Usage: 19 | ### Flask HTTP Server 20 | * Default port is 6478 21 | 22 | ### Input 23 | * Within `index.py` edit setup list with a RTSP stream URL: 24 | ```` 25 | {"cameraID":"example", "rtsp":"rtsp://192.168.0.186:554/...", "daysToKeep": 5} 26 | ```` 27 | * `cameraID`: will be the reference for this input 28 | * `rtsp`: url of the RTSP stream 29 | * `daysToKeep`: the number of previous HLS and MPEG day streams that should be kept 30 | 31 | 32 | ### HLS and MPEG-Dash 33 | * Handled in day streams 34 | * At the end of each day a new stream/playlist will be started 35 | * Allows for rewinding through current day 36 | * Keep the recordings of up to six full previous days 37 | * stream for current day is called today 38 | * For other days it is the number of the weekday: e.g. 0 = Monday ... 6 = Sunday 39 | * All segments and playlists are deleted automatically when not required 40 | * HLS stream 41 | * Today stream accessed via (cameraID should be replaced with the one provided in `index.py`): 42 | ```` 43 | http://127.0.0.1:6478/[cameraID]/stream/today/playlist.m3u8 44 | ```` 45 | * Previous day streams: 46 | ```` 47 | http://127.0.0.1:6478/[cameraID]/stream/[weekday number]/playlist.m3u8 48 | ```` 49 | * Dash stream 50 | * Today stream: 51 | ```` 52 | http://127.0.0.1:6478/[cameraID]/stream/today/playlist.m3u8 53 | ```` 54 | * Previous day streams: 55 | ```` 56 | http://127.0.0.1:6478/[cameraID]/stream/[weekday number]/playlist.m3u8 57 | ```` 58 | 59 | ### Timelapse 60 | * Each day is turned into a MP4 video with a duration of 60 seconds approximately 61 | * Generated with the live feed and is viewable only after the day has completed 62 | * List of available timelapses as JSON can be requested: 63 | ```` 64 | http://127.0.0.1:6478/[cameraID]/timelapse/ 65 | ```` 66 | * To view a specific timelapse: 67 | ```` 68 | http://127.0.0.1:6478/[cameraID]/timelapse/[year]/[month]/[day] 69 | ```` 70 | 71 | ### Frame 72 | * Outputs a JPEG image upon request 73 | * To generate and request a frame: 74 | ```` 75 | http://127.0.0.1:6478/[cameraID]/frame/ 76 | ```` 77 | 78 | ### Moment 79 | * Allows for the simultaneous generation of short GIFs and MP4s that show what happened in the up to 20 seconds before the request was made 80 | * The moment creation can be delayed up to 10 seconds beyond the time of the request. This allows for the event being captured to be in the middle of the clip rather than at the end. It shows what happened before and what happened after. 81 | * Each MP4 is saved for later viewing whereas the GIF is overwritten with each generation request 82 | * To generate a moment with a length of 5 seconds and no delay: 83 | ```` 84 | http://127.0.0.1:6478/[cameraID]/moment/generate/ 85 | ```` 86 | * To generate a moment with specific length (integer inclusive of 4 and 20) and delay (integer inclusive of 0 and 10): 87 | ```` 88 | http://127.0.0.1:6478/[cameraID]/moment/generate/[length]/[delay] 89 | ```` 90 | * To retrieve the generated GIF: 91 | ```` 92 | http://127.0.0.1:6478/[cameraID]/moment/gif 93 | ```` 94 | * List of available moments as JSON can be requested: 95 | ```` 96 | http://127.0.0.1:6478/[cameraID]/moment/list 97 | ```` 98 | * To view a specific timelapse: 99 | ```` 100 | http://127.0.0.1:6478/[cameraID]/moment/list/[year]/[month]/[day]/[hour]/[minute]/[second] 101 | ```` -------------------------------------------------------------------------------- /camera.py: -------------------------------------------------------------------------------- 1 | import threading, time, ffmpeg, json, sys 2 | import shared, export 3 | 4 | class main(threading.Thread): 5 | def __init__(self, url, daysToKeep): 6 | super(main, self).__init__() 7 | self.daemon = True 8 | self.url = url 9 | self.daysToKeep = daysToKeep 10 | def run(self): 11 | 12 | self.prevCaptureDay = shared.dayOfWeek() 13 | 14 | # Main FFMPEG Process 15 | def operate(self): 16 | while True: 17 | # Only want to record until midnight so that each day ends up in its respective folder 18 | captureSeconds = shared.getTimestampMidnight() 19 | # Get day of week and place hls/dash segments in correct folder 20 | captureDay = shared.dayOfWeek() 21 | 22 | # If false then it is a new day so clean up recordings and run other end of day things 23 | if self.prevCaptureDay != captureDay: 24 | shared.cleanRecordingsOnStart(self.name, captureDay, self.daysToKeep) 25 | self.prevCaptureDay = captureDay 26 | 27 | # Generate final timelapse 28 | export.timelapsePreviousDay(self.name) 29 | 30 | print("Starting") 31 | 32 | command = ( 33 | ffmpeg 34 | .input(self.url, rtsp_transport='tcp', stimeout='20000000', use_wallclock_as_timestamps=1, fflags='+genpts') 35 | .output('data/%s/days/%s/stream.mpd' % (self.name, captureDay), vcodec='copy', an=None, format='dash', seg_duration=2, window_size=86400, extra_window_size=2, hls_playlist=1, remove_at_exit=0) 36 | # ts segment output for moment 37 | .global_args('-f', 'segment') 38 | .global_args('-codec', 'copy') 39 | .global_args('-segment_time', '1') 40 | .global_args('-strftime', '1') 41 | .global_args('data/%s/moment/parts/%%M-%%S.ts'% (self.name)) 42 | # timelapse 43 | .global_args('-r', '60') 44 | .global_args('-filter:v', 'setpts=0.0006944444444*PTS') 45 | .global_args('-vcodec','libx264') 46 | .global_args('-crf','30') 47 | .global_args('-preset','slow') 48 | .global_args('-an') 49 | .global_args('data/%s/timelapses/parts/%s.ts'% (self.name, shared.nowDateString())) 50 | ) 51 | process = command.run_async(pipe_stdin=True, quiet=False) 52 | 53 | while True: 54 | if process.poll() == None: 55 | # If process is still running, check the current time against captureSeconds to see if it should end 56 | if shared.compareTimestampMidnight(captureSeconds): 57 | # True so it is a new day, end the process safely 58 | print("New Day") 59 | process.communicate(str.encode("q")) 60 | time.sleep(3) 61 | process.terminate() 62 | break 63 | else: 64 | # Not running may have ended in error or completed with success, so restart 65 | print("not Running") 66 | time.sleep(10) 67 | break 68 | time.sleep(1) 69 | 70 | print('Restart %s'% (shared.nowDateString())) 71 | 72 | #Create file structure 73 | shared.createStructure(self.name) 74 | 75 | #Starting a new recording so clean out folder, can't continue previous recordings even if on same day 76 | shared.cleanRecordingsOnStart(self.name, self.prevCaptureDay, self.daysToKeep) 77 | 78 | operate(self) 79 | -------------------------------------------------------------------------------- /export.py: -------------------------------------------------------------------------------- 1 | import threading, time, ffmpeg, json, sys, shared, os, re 2 | from shutil import copy 3 | from datetime import datetime 4 | from datetime import timedelta 5 | from pathlib import Path 6 | 7 | def jpg (name): 8 | # Update the frame jpg upon request 9 | # Latest file could be 32-57 or 32-58 as ts recording happens in two second segments 10 | # So get list of files in directory, sort them so most recent is index 0, then input index 1 into ffmpeg process 11 | 12 | items = os.listdir("data/%s/moment/parts" % (name)) 13 | items.sort(reverse = True) 14 | 15 | try: 16 | ( 17 | ffmpeg 18 | .input('data/%s/moment/parts/%s' % (name, items[1])) 19 | .output('data/%s/frame.jpg' % (name), f='image2', vframes='1') 20 | .global_args('-y') 21 | .run(capture_stdout=True, capture_stderr=True) 22 | ) 23 | return True 24 | except ffmpeg.Error as e: 25 | print(e.stderr.decode(), file=sys.stderr) 26 | return False 27 | 28 | def moment (name, directory, length, delay): 29 | # Generate a gif and mp4 for the specified camera. 30 | # Gif is saved always as moment.gif meaning that it is overwritten everytime 31 | # MP4 is saved always as year-month-day-hour-minute-second.mp4 meaning that each moment is kept 32 | # directory (bool) == return the directory where moments are kept 33 | # length (min 4 sec, max 20 secs) (1 second will be added to ensure length) == seconds that the replay should be 34 | # delay (0 = no delay, max 10 secs) == how long to wait before starting ffmpeg process, allows for capturing before and after the event 35 | 36 | if directory == True: 37 | if name in shared.handledCameras: 38 | return os.path.abspath('data/%s/moment' % (name)) 39 | else: 40 | return False 41 | 42 | else: 43 | if length > 20: 44 | length = 20 45 | elif length < 4: 46 | length = 4 47 | 48 | if delay > 10: 49 | delay = 10 50 | 51 | items = os.listdir("data/%s/moment/parts" % (name)) 52 | 53 | if len(items) >= (length + 1): 54 | 55 | if delay != 0: 56 | time.sleep(delay) 57 | 58 | # Retrieve list of dir again as delay may have changed its contents 59 | items = os.listdir("data/%s/moment/parts" % (name)) 60 | items.sort(reverse = True) 61 | items.pop(0) 62 | 63 | filenames = [] 64 | 65 | for i in range(length): 66 | filenames.insert(0,'data/%s/moment/parts/%s' % (name, items[i])) 67 | 68 | finalInput = 'concat:%s' % ('|'.join(filenames)) 69 | 70 | now = datetime.now() 71 | videoName = '%s-%s-%s-%s-%s-%s' % (now.strftime("%Y"),now.strftime("%-m"),now.strftime("%-d"),now.strftime("%k"),now.strftime("%M"), now.strftime("%S")) 72 | 73 | try: 74 | ( 75 | ffmpeg 76 | .input(finalInput) 77 | .output('data/%s/moment/moment.gif' % (name), f='gif') 78 | .global_args('-y') 79 | # MP4 Output 80 | .global_args('-vcodec', 'copy') 81 | .global_args('-an') 82 | .global_args('data/%s/moment/%s.mp4'% (name, videoName)) 83 | .run(capture_stdout=True, capture_stderr=True) 84 | ) 85 | return {"gif": os.path.abspath('data/%s/moment/moment.gif' % (name)), "mp4": os.path.abspath('../miro_data/monitor/%s/moment/%s.mp4'% (name, videoName)), "filename": [now.strftime("%Y"),now.strftime("%-m"),now.strftime("%-d"),now.strftime("%k"),now.strftime("%M"), now.strftime("%S")]} 86 | except ffmpeg.Error as e: 87 | print(e.stderr.decode(), file=sys.stderr) 88 | return False 89 | 90 | else: 91 | return False 92 | 93 | 94 | def timelapsePreviousDay (name): 95 | # Concat the previous day timelapses into a single timelapse 96 | items = os.listdir("data/%s/timelapses/parts" % (name)) 97 | 98 | t = datetime.now() - timedelta(days = 1) 99 | 100 | filenames = [] 101 | 102 | for x in items: 103 | # 2020-2-27-19-58-55.mp4 104 | if x.startswith('%s-%s-%s' % (t.strftime("%Y"), t.strftime("%-m"), t.strftime("%-d"))) and x.endswith(".ts"): 105 | # filenames.append(x) 106 | try: 107 | probe = ffmpeg.probe('data/%s/timelapses/parts/%s' % (name, x)) 108 | 109 | if len(probe["streams"]) > 0: 110 | # Only add if the file contains stream data 111 | filenames.insert(0,'data/%s/timelapses/parts/%s' % (name, x)) 112 | except ffmpeg.Error as e: 113 | print(e.stderr, file=sys.stderr) 114 | 115 | if len(filenames) > 0: 116 | 117 | filenames.sort() 118 | 119 | finalInput = 'concat:%s' % ('|'.join(filenames)) 120 | 121 | try: 122 | ( 123 | ffmpeg 124 | .input(finalInput) 125 | .output('data/%s/timelapses/%s-%s-%s.mp4' % (name, t.strftime("%Y"), t.strftime("%-m"), t.strftime("%-d")), vcodec='copy', an=None) 126 | .global_args('-y') 127 | .run(capture_stdout=True, capture_stderr=True) 128 | ) 129 | 130 | # If was successful so delete original files 131 | for x in filenames: 132 | os.remove(x) 133 | except ffmpeg.Error as e: 134 | print(e.stderr.decode(), file=sys.stderr) 135 | return False -------------------------------------------------------------------------------- /index.py: -------------------------------------------------------------------------------- 1 | import threading, time 2 | import shared, export, camera, web 3 | 4 | setup = [ 5 | {"cameraID":"exampleone", "rtsp":"rtsp://192.168.0.186:554/...", "daysToKeep": 5}, 6 | {"cameraID":"exampletwo", "rtsp":"rtsp://192.168.0.46:554/...", "daysToKeep": 1} 7 | ] 8 | 9 | webThread = web.main() 10 | webThread.daemon = True 11 | webThread.start() 12 | 13 | def request_handler(req): 14 | shared.handledCameras.append(req["cameraID"]) 15 | cameraThread = camera.main(req["rtsp"], req["daysToKeep"]) 16 | cameraThread.name = req["cameraID"] 17 | cameraThread.daemon = True 18 | cameraThread.start() 19 | 20 | for x in setup: 21 | request_handler(x) 22 | 23 | while True: 24 | time.sleep(10) 25 | for x in shared.handledCameras: 26 | shared.tidyMoment(x) -------------------------------------------------------------------------------- /shared.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from datetime import timedelta 3 | from pathlib import Path 4 | import re, os 5 | 6 | debug = False 7 | 8 | handledCameras = [] 9 | 10 | def dayOfWeek(): 11 | today = datetime.now() 12 | return today.weekday() 13 | 14 | def secondsUntilMidnight(): 15 | now = datetime.now() 16 | # Add an extra 60 seconds to ensure that the time passes midnight 17 | return (((24 - now.hour - 1) * 60 * 60) + ((60 - now.minute - 1) * 60) + (60 - now.second) + 60) 18 | 19 | def getTimestampMidnight(): 20 | # Add an extra 20 seconds to ensure that the time passes midnight 21 | midnight = (int(datetime.timestamp(datetime.combine(datetime.now(), datetime.max.time())))+20) 22 | return midnight 23 | 24 | def compareTimestampMidnight(value): 25 | # Provide the known midnight (captureSeconds) and this function determines if the current time is before or after that value 26 | now = (int(datetime.timestamp(datetime.now()))) 27 | if value >= now: 28 | # Before value, so same day 29 | return False 30 | else: 31 | # After value, so new day 32 | return True 33 | 34 | def todayDateString(): 35 | now = datetime.now() 36 | return ('%s-%s-%s' % (now.year, now.month, now.day)) 37 | 38 | def nowDateString(): 39 | now = datetime.now() 40 | return ('%s-%s-%s-%s-%s-%s' % (now.year, now.month, now.day, now.hour, now.minute, now.second)) 41 | 42 | def createStructure(name): 43 | Path("data/%s/days/0" % name).mkdir(parents=True, exist_ok=True) # Monday 44 | Path("data/%s/days/1" % name).mkdir(parents=True, exist_ok=True) # Tuesday 45 | Path("data/%s/days/2" % name).mkdir(parents=True, exist_ok=True) # Wednesday 46 | Path("data/%s/days/3" % name).mkdir(parents=True, exist_ok=True) # Thursday 47 | Path("data/%s/days/4" % name).mkdir(parents=True, exist_ok=True) # Friday 48 | Path("data/%s/days/5" % name).mkdir(parents=True, exist_ok=True) # Saturday 49 | Path("data/%s/days/6" % name).mkdir(parents=True, exist_ok=True) # Sunday 50 | Path("data/%s/timelapses/parts" % name).mkdir(parents=True, exist_ok=True) 51 | Path("data/%s/moment/parts" % name).mkdir(parents=True, exist_ok=True) 52 | 53 | def cleanRecordingsOnStart(name, day, limit): 54 | # Deletes recording within the folder for the day provided and those days not covered by the limit 55 | # Also deletes all files within replay folder 56 | # Day is current day of week 57 | # Limit is how many days to keep, if 3 then the previous three days before the current will be kept, the other 3 days will be deleted 58 | 59 | allDays = [0,1,2,3,4,5,6] 60 | daysToKeep = [] 61 | 62 | #Work out days to keep 63 | for i in range((day - limit), day): 64 | daysToKeep.append(allDays[i]) 65 | 66 | #Get List of files in respective directories and then delete the files 67 | for i in allDays: 68 | if i not in daysToKeep: 69 | items = os.listdir("data/%s/days/%s" % (name, i)) 70 | for x in items: 71 | os.remove("data/%s/days/%s/%s" % (name, i, x)) 72 | 73 | replayItems = os.listdir("data/%s/moment/parts" % (name)) 74 | for x in replayItems: 75 | os.remove("data/%s/moment/parts/%s" % (name, x)) 76 | 77 | def tidyMoment(name): 78 | # Removes ts files from the moment folder that are older than a minute 79 | 80 | replayItems = os.listdir("data/%s/moment/parts/" % (name)) 81 | t = datetime.now() - timedelta(minutes = 2) 82 | 83 | for x in replayItems: 84 | if re.search('%s-\d\d.ts' % (t.strftime("%M")),x) != None: 85 | os.remove("data/%s/moment/parts/%s" % (name, x)) -------------------------------------------------------------------------------- /web.py: -------------------------------------------------------------------------------- 1 | from flask import render_template, Flask, send_from_directory, abort, json #sudo python3 -m pip install flask 2 | import threading, os 3 | from pathlib import Path 4 | import shared, export 5 | 6 | class main(threading.Thread): 7 | def __init__(self): 8 | super(main, self).__init__() 9 | self.daemon = True 10 | def run(self): 11 | 12 | app = Flask(__name__) 13 | 14 | @app.after_request 15 | def add_header(response): 16 | response.headers['X-UA-Compatible'] = 'IE=Edge,chrome=1' 17 | response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' 18 | response.headers['Pragma'] = 'no-cache' 19 | response.headers['Expires'] = '0' 20 | response.headers['Access-Control-Allow-Origin'] = '*' 21 | return response 22 | 23 | @app.route('//stream//') 24 | def streamToday(id, day, filename): 25 | loc = None 26 | 27 | if day == 'today': 28 | loc = 'data/%s/days/%s' % (id, shared.dayOfWeek()) 29 | elif day == '0': 30 | loc = 'data/%s/days/0' % (id) 31 | elif day == '1': 32 | loc = 'data/%s/days/1' % (id) 33 | elif day == '2': 34 | loc = 'data/%s/days/2' % (id) 35 | elif day == '3': 36 | loc = 'data/%s/days/3' % (id) 37 | elif day == '4': 38 | loc = 'data/%s/days/4' % (id) 39 | elif day == '5': 40 | loc = 'data/%s/days/5' % (id) 41 | elif day == '6': 42 | loc = 'data/%s/days/6' % (id) 43 | else: 44 | abort(404) 45 | 46 | if filename == 'playlist.m3u8': 47 | # HLS Playlist Request 48 | if Path('%s/media_0.m3u8' % (loc)).is_file(): 49 | return send_from_directory(loc, filename='media_0.m3u8') 50 | else: 51 | abort(404) 52 | elif filename == 'playlist.mpd': 53 | # Dash Playlist Request 54 | if Path('%s/stream.mpd' % (loc)).is_file(): 55 | return send_from_directory(loc, filename='stream.mpd') 56 | else: 57 | abort(404) 58 | else: 59 | # HLS or Dash Segment Request 60 | if Path('%s/%s' % (loc, filename)).is_file(): 61 | return send_from_directory(loc, filename=filename) 62 | else: 63 | abort(404) 64 | 65 | @app.route('//frame') 66 | def frame(id): 67 | if export.jpg(id): 68 | return send_from_directory(directory='data/%s' % (id), filename='frame.jpg') 69 | else: 70 | abort(404) 71 | 72 | 73 | @app.route('//moment/generate/', defaults={'delay': 0, 'length': 5}) 74 | @app.route('//moment/generate/', defaults={'delay': 0}) 75 | @app.route('//moment/generate//') 76 | def moment_generate(id, length, delay): 77 | if export.moment(id, False, length, delay): 78 | return send_from_directory(directory='data/%s/moment' % (id), filename='moment.gif') 79 | else: 80 | abort(404) 81 | 82 | @app.route('//moment/list/', defaults={'year': None, 'month': None, 'day': None, 'hour': None, 'minute': None, 'second': None}) 83 | @app.route('//moment/list///////') 84 | def moment_list(id, year, month, day, hour, minute, second): 85 | res = export.moment(id, True, False, False) 86 | if res != False: 87 | if year == None: 88 | items = [] 89 | for x in os.listdir(res): 90 | if x.endswith(".mp4"): 91 | row = os.path.splitext(x)[0].split("-") 92 | items.append([int(i) for i in row]) 93 | 94 | response = app.response_class( 95 | response=json.dumps(items), 96 | status=200, 97 | mimetype='application/json' 98 | ) 99 | return response 100 | else: 101 | return send_from_directory(directory='data/%s/moment' % (id), filename='%s-%s-%s-%s-%s-%s.mp4' % (year, month, day, hour, minute, second)) 102 | else: 103 | abort(404) 104 | 105 | @app.route('//moment/gif/') 106 | def moment_gif(id): 107 | res = export.moment(id, True, False, False) 108 | if res != False: 109 | return send_from_directory(directory='data/%s/moment' % (id), filename='moment.gif') 110 | else: 111 | abort(404) 112 | 113 | @app.route('//timelapse/', defaults={'year': None, 'month': None, 'day': None}) 114 | @app.route('//timelapse////') 115 | def timelapse(id, year, month, day): 116 | if id in shared.handledCameras: 117 | 118 | if year == None: 119 | # Return a list of available timelapses 120 | items = [] 121 | for x in os.listdir('data/%s/timelapses' % (id)): 122 | if x.endswith(".mp4"): 123 | row = os.path.splitext(x)[0].split("-") 124 | items.append([int(i) for i in row]) 125 | 126 | response = app.response_class( 127 | response=json.dumps(items), 128 | status=200, 129 | mimetype='application/json' 130 | ) 131 | return response 132 | 133 | else: 134 | # Return requested timelapse if it exists 135 | return send_from_directory(directory='data/%s/timelapses' % (id), filename='%s-%s-%s.mp4' % (year, month, day)) 136 | else: 137 | abort(404) 138 | 139 | app.run(host='0.0.0.0', port=6478) --------------------------------------------------------------------------------