From fcabde903627c3cc16c5e22d9281a377579f9047 Mon Sep 17 00:00:00 2001 From: Mrnt Date: Wed, 7 Mar 2018 11:45:58 -0800 Subject: [PATCH] Add video playback support - The StaticFileHandler gives us automatic support for mime type and partial file download using the Range request header. - Subclass static handler to support video playback and also fo download - js changes to preload video and then show video node if video loads - progress indicator while video downloading - error indicator when video failed to load with error message - do not show video on older devices - hide video if there is a problem - reset width and height properly on every preview load --- motioneye/handlers.py | 122 +++++++++++++++++++++++----------- motioneye/mediafiles.py | 8 +++ motioneye/server.py | 4 +- motioneye/static/css/main.css | 24 ++++++- motioneye/static/js/main.js | 111 +++++++++++++++++++++++++++++-- 5 files changed, 222 insertions(+), 47 deletions(-) diff --git a/motioneye/handlers.py b/motioneye/handlers.py index 4fe9125..96b6118 100644 --- a/motioneye/handlers.py +++ b/motioneye/handlers.py @@ -19,14 +19,13 @@ import datetime import hashlib import json import logging -import mimetypes import os import re import socket import subprocess from tornado.ioloop import IOLoop -from tornado.web import RequestHandler, HTTPError, asynchronous +from tornado.web import RequestHandler, StaticFileHandler, HTTPError, asynchronous import config import mediafiles @@ -1452,9 +1451,6 @@ class MovieHandler(BaseHandler): if op == 'list': self.list(camera_id) - elif op == 'download': - self.download(camera_id, filename) - elif op == 'preview': self.preview(camera_id, filename) @@ -1512,38 +1508,6 @@ class MovieHandler(BaseHandler): else: # assuming simple mjpeg camera raise HTTPError(400, 'unknown operation') - @BaseHandler.auth() - def download(self, camera_id, filename): - logging.debug('downloading movie %(filename)s of camera %(id)s' % { - 'filename': filename, 'id': camera_id}) - - camera_config = config.get_camera(camera_id) - if utils.is_local_motion_camera(camera_config): - content = mediafiles.get_media_content(camera_config, filename, 'movie') - - pretty_filename = camera_config['@name'] + '_' + os.path.basename(filename) - self.set_header('Content-Type', mimetypes.guess_type(filename)[0] or 'video/mpeg') - self.set_header('Content-Disposition', 'attachment; filename=' + pretty_filename + ';') - - self.finish(content) - - elif utils.is_remote_camera(camera_config): - def on_response(response=None, error=None): - if error: - return self.finish_json({'error': 'Failed to download movie from %(url)s: %(msg)s.' % { - 'url': remote.pretty_camera_url(camera_config), 'msg': error}}) - - pretty_filename = os.path.basename(filename) # no camera name available w/o additional request - self.set_header('Content-Type', mimetypes.guess_type(filename)[0] or 'video/mpeg') - self.set_header('Content-Disposition', 'attachment; filename=' + pretty_filename + ';') - - self.finish(response) - - remote.get_media_content(camera_config, filename=filename, media_type='movie', callback=on_response) - - else: # assuming simple mjpeg camera - raise HTTPError(400, 'unknown operation') - @BaseHandler.auth() def preview(self, camera_id, filename): logging.debug('previewing movie %(filename)s of camera %(id)s' % { @@ -1638,6 +1602,90 @@ class MovieHandler(BaseHandler): raise HTTPError(400, 'unknown operation') +# support fetching movies with authentication +class MoviePlaybackHandler(StaticFileHandler, BaseHandler): + import tempfile + tmpdir = tempfile.gettempdir() + '/MotionEye' + + @asynchronous + @BaseHandler.auth() + def get(self, camera_id, filename=None, include_body=True): + logging.debug('downloading movie %(filename)s of camera %(id)s' % { + 'filename': filename, 'id': camera_id}) + + self.pretty_filename = os.path.basename(filename) + + if camera_id is not None: + camera_id = int(camera_id) + if camera_id not in config.get_camera_ids(): + raise HTTPError(404, 'no such camera') + + camera_config = config.get_camera(camera_id) + + if utils.is_local_motion_camera(camera_config): + filename = mediafiles.get_media_path(camera_config, filename, 'movie') + self.pretty_filename = camera_config['@name'] + '_' + self.pretty_filename + return StaticFileHandler.get(self, filename, include_body=include_body) + + elif utils.is_remote_camera(camera_config): + + def on_response(response=None, error=None): + if error: + return self.finish_json({'error': 'Failed to download movie from %(url)s: %(msg)s.' % { + 'url': remote.pretty_camera_url(camera_config), 'msg': error}}) + + # check if the file has been created by another request while we were fetching the movie + if not os.path.isfile(tmpfile): + tmp = open(tmpfile, 'wb') + tmp.write(response) + tmp.close() + + return StaticFileHandler.get(self, tmpfile, include_body=include_body) + + # we will cache the movie since it takes a while to fetch from the remote camera + # and we may be going to play it back in the browser, which will fetch the video in chunks + tmpfile = self.tmpdir+'/'+self.pretty_filename + if os.path.isfile(tmpfile): + # have a cached copy, update the timestamp so it's not flushed + import time + mtime = os.stat(tmpfile).st_mtime + os.utime(tmpfile, (time.time(), mtime)) + return StaticFileHandler.get(self, tmpfile, include_body=include_body) + + if not os.path.exists(self.tmpdir): + os.mkdir(self.tmpdir) + remote.get_media_content(camera_config, filename, media_type='movie', callback=on_response) + + else: # assuming simple mjpeg camera + raise HTTPError(400, 'unknown operation') + + def on_finish(self): + import time + # delete any cached file older than an hour + stale_time = time.time() - (60 * 60) + try: + for f in os.listdir(self.tmpdir): + f = os.path.join(self.tmpdir, f) + if os.path.isfile(f) and os.stat(f).st_atime <= stale_time: + os.remove(f) + except: + logging.error('could not delete temp file') + pass + + def get_absolute_path(self, root, path): + return path + + def validate_absolute_path(self, root, absolute_path): + return absolute_path + + +class MovieDownloadHandler(MoviePlaybackHandler): + def set_extra_headers(self, filename): + if (self.get_status() in (200, 304)): + self.set_header('Content-Disposition','attachment; filename=' + self.pretty_filename + ';') + self.set_header('Expires','0') + + class ActionHandler(BaseHandler): @asynchronous def post(self, camera_id, action): diff --git a/motioneye/mediafiles.py b/motioneye/mediafiles.py index bdca31e..89d88f8 100644 --- a/motioneye/mediafiles.py +++ b/motioneye/mediafiles.py @@ -374,6 +374,7 @@ def list_media(camera_config, media_type, callback, prefix=None): # create a subprocess to retrieve media files def do_list_media(pipe): + import mimetypes parent_pipe.close() mf = _list_media_files(target_dir, exts=exts, prefix=prefix) @@ -387,6 +388,7 @@ def list_media(camera_config, media_type, callback, prefix=None): pipe.send({ 'path': path, + 'mimeType': mimetypes.guess_type(path)[0] or 'video/mpeg', 'momentStr': utils.pretty_date_time(datetime.datetime.fromtimestamp(timestamp)), 'momentStrShort': utils.pretty_date_time(datetime.datetime.fromtimestamp(timestamp), short=True), 'sizeStr': utils.pretty_size(size), @@ -441,6 +443,12 @@ def list_media(camera_config, media_type, callback, prefix=None): poll_process() +def get_media_path(camera_config, path, media_type): + target_dir = camera_config.get('target_dir') + full_path = os.path.join(target_dir, path) + return full_path + + def get_media_content(camera_config, path, media_type): target_dir = camera_config.get('target_dir') diff --git a/motioneye/server.py b/motioneye/server.py index 7ff1313..f21d66f 100644 --- a/motioneye/server.py +++ b/motioneye/server.py @@ -175,8 +175,10 @@ handler_mapping = [ (r'^/picture/(?P\d+)/(?Pdownload|preview|delete)/(?P.+?)/?$', handlers.PictureHandler), (r'^/picture/(?P\d+)/(?Pzipped|timelapse|delete_all)/(?P.*?)/?$', handlers.PictureHandler), (r'^/movie/(?P\d+)/(?Plist)/?$', handlers.MovieHandler), - (r'^/movie/(?P\d+)/(?Pdownload|preview|delete)/(?P.+?)/?$', handlers.MovieHandler), + (r'^/movie/(?P\d+)/(?Ppreview|delete)/(?P.+?)/?$', handlers.MovieHandler), (r'^/movie/(?P\d+)/(?Pdelete_all)/(?P.*?)/?$', handlers.MovieHandler), + (r'^/movie/(?P\d+)/playback/(?P.+?)/?$', handlers.MoviePlaybackHandler,{'path':r''}), + (r'^/movie/(?P\d+)/download/(?P.+?)/?$', handlers.MovieDownloadHandler,{'path':r''}), (r'^/action/(?P\d+)/(?P\w+)/?$', handlers.ActionHandler), (r'^/prefs/(?P\w+)?/?$', handlers.PrefsHandler), (r'^/_relay_event/?$', handlers.RelayEventHandler), diff --git a/motioneye/static/css/main.css b/motioneye/static/css/main.css index 4ad129e..f912b9e 100644 --- a/motioneye/static/css/main.css +++ b/motioneye/static/css/main.css @@ -768,7 +768,8 @@ div.picture-dialog-content { div.picture-dialog-prev-arrow, div.picture-dialog-next-arrow { position: absolute; - top: 45%; + top: 50%; + margin-top: -1.5em; background-color: rgba(0, 0, 0, 0.6); background-image: url(../img/arrows.svg); background-size: cover; @@ -790,7 +791,10 @@ div.picture-dialog-next-arrow { background-position: -100% 0%; } -img.picture-dialog-content { +img.picture-dialog-content, +video.picture-dialog-content { + display: block; + margin: 0 auto; border: 1px solid #292929; } @@ -801,6 +805,22 @@ img.picture-dialog-progress { border-radius: 10px; opacity: 0.7; } +.video-loading { + position: absolute; + top: 0; + left: 0; + margin-top: -5px; + height: 2.5em; + width: 2.5em; + background-image: url(../img/small-progress.gif); + background-position: center; + background-repeat: no-repeat; +} +.video-loaderror { + background-image: url(../img/validation-error.svg); + background-position: center; + cursor: pointer; +} table.timelapse-dialog select { width: 10em; diff --git a/motioneye/static/js/main.js b/motioneye/static/js/main.js index 1097a06..2341aa9 100644 --- a/motioneye/static/js/main.js +++ b/motioneye/static/js/main.js @@ -3450,8 +3450,21 @@ function runLoginDialog(retry) { function runPictureDialog(entries, pos, mediaType) { var content = $('
'); - + var video = videoEl = videoLoader = vProgressImg = null; var img = $(''); + + if (mediaType == 'movie') { + video = $('