- 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
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
if op == 'list':
self.list(camera_id)
- elif op == 'download':
- self.download(camera_id, filename)
-
elif op == 'preview':
self.preview(camera_id, filename)
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' % {
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):
# 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)
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),
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')
(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),
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;
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;
}
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;
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>');
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];
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();
$('body').off('keydown', bodyKeyDown);
}
});
+ if (videoEl != null) {
+ $('div.modal-container').find('.modal-title-bar:last').append(vProgressImg);
+ }
}
function runAddCameraDialog() {
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;