]> www.vanbest.org Git - motioneye-debian/commitdiff
Add video playback support
authorMrnt <github@maurent.com>
Wed, 7 Mar 2018 19:45:58 +0000 (11:45 -0800)
committerMrnt <github@maurent.com>
Wed, 7 Mar 2018 19:53:53 +0000 (11:53 -0800)
 - 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
motioneye/mediafiles.py
motioneye/server.py
motioneye/static/css/main.css
motioneye/static/js/main.js

index 4fe9125f47fb925ceb27bf54b9c0967c23cb5ab5..96b61180d989dfe8e618c40c9fc3ecb6349f2369 100644 (file)
@@ -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):
index bdca31e780546cc0a9781736ba9da1f0dc3a16f6..89d88f812ebb2ada901356be8fadc7579775344b 100644 (file)
@@ -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')
 
index 7ff1313bd121d12dec0028ba9648d0150d7a1aca..f21d66f38140e34b9baa9c9096eae54518a5eb4b 100644 (file)
@@ -175,8 +175,10 @@ handler_mapping = [
     (r'^/picture/(?P<camera_id>\d+)/(?P<op>download|preview|delete)/(?P<filename>.+?)/?$', handlers.PictureHandler),
     (r'^/picture/(?P<camera_id>\d+)/(?P<op>zipped|timelapse|delete_all)/(?P<group>.*?)/?$', handlers.PictureHandler),
     (r'^/movie/(?P<camera_id>\d+)/(?P<op>list)/?$', handlers.MovieHandler),
-    (r'^/movie/(?P<camera_id>\d+)/(?P<op>download|preview|delete)/(?P<filename>.+?)/?$', handlers.MovieHandler),
+    (r'^/movie/(?P<camera_id>\d+)/(?P<op>preview|delete)/(?P<filename>.+?)/?$', handlers.MovieHandler),
     (r'^/movie/(?P<camera_id>\d+)/(?P<op>delete_all)/(?P<group>.*?)/?$', handlers.MovieHandler),
+    (r'^/movie/(?P<camera_id>\d+)/playback/(?P<filename>.+?)/?$', handlers.MoviePlaybackHandler,{'path':r''}),
+    (r'^/movie/(?P<camera_id>\d+)/download/(?P<filename>.+?)/?$', handlers.MovieDownloadHandler,{'path':r''}),
     (r'^/action/(?P<camera_id>\d+)/(?P<action>\w+)/?$', handlers.ActionHandler),
     (r'^/prefs/(?P<key>\w+)?/?$', handlers.PrefsHandler),
     (r'^/_relay_event/?$', handlers.RelayEventHandler),
index 4ad129e24035d3e449c3db03341e58917e48bcaa..f912b9e0b52610dc64eca8d37a679d456b1bf77e 100644 (file)
@@ -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;
index 1097a0655c4a250188f3c787a1c2c38e03473189..2341aa9d0c807893370a8367718a1fe87f78799f 100644 (file)
@@ -3450,8 +3450,21 @@ function runLoginDialog(retry) {
 
 function runPictureDialog(entries, pos, mediaType) {
     var content = $('<div class="picture-dialog-content"></div>');
-    
+    var video = videoEl = videoLoader = vProgressImg = null;
     var img = $('<img class="picture-dialog-content">');
+
+    if (mediaType == 'movie') {
+        video = $('<video class="picture-dialog-content" preload="metadata" style="display:none;" controls="controls" >');
+        if (video.get(0).canPlayType) {
+            content.append(video);
+            videoEl = video.get(0);
+            videoLoader = $('<img>');
+            vProgressImg = $('<div class="video-loading" style="display:none;">');
+            vProgressImg.on('click', function() {
+                displayVideoError(videoEl.error);
+            });
+        }
+    }
     content.append(img);
     
     var prevArrow = $('<div class="picture-dialog-prev-arrow button mouse-effect" title="previous picture"></div>');
@@ -3462,6 +3475,27 @@ function runPictureDialog(entries, pos, mediaType) {
     
     var progressImg = $('<img class="picture-dialog-progress" src="' + staticPath + 'img/modal-progress.gif">');
     
+    function displayVideoError(e) {
+        var errMsg;
+        if (e == null)
+            return;
+        switch (e.code) {
+            case e.MEDIA_ERR_ABORTED:
+                errMsg = 'Video download aborted';
+                break;
+            case e.MEDIA_ERR_NETWORK:
+                errMsg = 'Video download failed - network connection error';
+                break;
+            case e.MEDIA_ERR_DECODE:
+                errMsg = 'Video cannot be played due to error while decoding - video file may be corrupt';
+                break;
+            case e.MEDIA_ERR_SRC_NOT_SUPPORTED:
+            default:
+                errMsg = 'Video encoding format not compatable with this browser - try viewing on a different browser or another type of device';
+        }
+        alert(errMsg);
+    }
+    
     function updatePicture() {
         var entry = entries[pos];
 
@@ -3471,32 +3505,91 @@ function runPictureDialog(entries, pos, mediaType) {
         var heightCoef = 0.75;
         
         var width = parseInt(windowWidth * widthCoef);
-        var height = parseInt(windowHeight * heightCoef);        
+        var height = parseInt(windowHeight * heightCoef);
         
         prevArrow.css('display', 'none');
         nextArrow.css('display', 'none');
-        img.parent().append(progressImg);
+
+        if (videoEl != null) {
+            if (videoEl.currentTime != 0) {
+                videoEl.pause();
+                videoEl.currentTime = 0;
+            }
+            video.hide();
+            img.show();
+            videoLoader.removeAttr('src');
+            vProgressImg.removeClass('video-loaderror');
+        }
+
         updateModalDialogPosition();
+        img.parent().append(progressImg);
         progressImg.css('left', (img.parent().width() - progressImg.width()) / 2);
         progressImg.css('top', (img.parent().height() - progressImg.height()) / 2);
         
-        img.attr('src', addAuthParams('GET', basePath + mediaType + '/' + entry.cameraId + '/preview' + entry.path));
-        img.load(function () {
-            var aspectRatio = this.naturalWidth / this.naturalHeight;
+        function mediaLoaded(naturalWidth, naturalHeight) {
+            var aspectRatio = naturalWidth / naturalHeight;
             var sizeWidth = width * width / aspectRatio;
             var sizeHeight = height * aspectRatio * height;
-            
+
+            img.width('').height('');
+            if (videoEl != null)
+                video.width('').height('');
+
             if (sizeWidth < sizeHeight) {
                 img.width(width);
+                if (videoEl != null)
+                    video.width(width);
             }
             else {
                 img.height(height);
+                if (videoEl != null)
+                    video.height(height);
             }
             updateModalDialogPosition();
             prevArrow.css('display', pos < entries.length - 1 ? '' : 'none');
             nextArrow.css('display', pos > 0 ? '' : 'none');
             progressImg.remove();
+        }
+        
+        img.one('load', function () {
+            mediaLoaded(this.naturalWidth, this.naturalHeight);
         });
+        img.attr('src', addAuthParams('GET', basePath + mediaType + '/' + entry.cameraId + '/preview' + entry.path));
+
+        if (videoEl != null) {
+            video.one('loadstart', function() {
+                /* older iOS browsers get this far then video tag needs to be made visible for it to 
+                 * fionish loading, but since they don't work well we wont bother
+                 */
+                vProgressImg.hide();
+            });
+            video.one('loadedmetadata', function() {
+                // enough video loaded it will have detected any codec errors and maybe has dimensions
+                if (videoEl.videoWidth && videoEl.videoHeight)
+                    mediaLoaded(videoEl.videoWidth, videoEl.videoHeight);
+                video.show();
+                img.hide();
+                vProgressImg.hide();
+            });
+            video.one('error', function(event) {
+                video.hide();
+                img.show();
+                vProgressImg.show().addClass('video-loaderror');
+            });
+            videoLoader.one('load error', function() {
+                video.attr({
+                    src: addAuthParams('GET', basePath + mediaType + '/' + entry.cameraId + '/playback' + entry.path),
+                    poster: addAuthParams('GET', basePath + mediaType + '/' + entry.cameraId + '/preview' + entry.path),
+                    type: entry.mimeType
+                });
+                // make old Android Chrome load video
+                videoEl.load();
+            });
+            vProgressImg.show();
+            videoLoader.attr({
+                src: addAuthParams('GET', basePath + mediaType + '/' + entry.cameraId + '/playback' + entry.path)
+            });
+        }
         
         $('div.modal-container').find('span.modal-title:last').html(entry.name);
         updateModalDialogPosition();
@@ -3557,6 +3650,9 @@ function runPictureDialog(entries, pos, mediaType) {
             $('body').off('keydown', bodyKeyDown);
         }
     });
+    if (videoEl != null) {
+        $('div.modal-container').find('.modal-title-bar:last').append(vProgressImg);
+    }
 }
 
 function runAddCameraDialog() {
@@ -4172,6 +4268,7 @@ function runMediaDialog(cameraId, mediaType) {
                 entries.forEach(function (entry) {
                     var media = mediaListByName[entry.name];
                     if (media) {
+                        entry.mimeType = media.mimeType
                         entry.momentStr = media.momentStr;
                         entry.momentStrShort = media.momentStrShort;
                         entry.sizeStr = media.sizeStr;