import datetime
import json
import logging
+import mimetypes
import os
import re
import socket
import subprocess
+from tornado import httputil
from tornado.ioloop import IOLoop
-from tornado.web import RequestHandler, HTTPError, asynchronous
+from tornado.web import RequestHandler, StaticFileHandler, HTTPError, asynchronous
import config
import mediafiles
self.set_header('Content-Type', 'image/jpeg')
self.set_header('Content-Disposition', 'attachment; filename=' + pretty_filename + ';')
- self.finish(response)
+ self.finish(response.body)
remote.get_media_content(camera_config, filename=filename, media_type='picture', callback=on_response)
'filename': filename, 'id': camera_id})
camera_config = config.get_camera(camera_id)
+
+ # To facilitiate cross-browser HTML5 <video> playback we need
+ # to support HTTP Range requests.
+ # (The below adapted from Tornado's StaticFileHandler)
+ request_range = None
+ range_header = self.request.headers.get("Range")
+ if range_header:
+ request_range = httputil._parse_request_range(range_header)
+
if utils.local_motion_camera(camera_config):
- content = mediafiles.get_media_content(camera_config, filename, 'movie')
-
+ full_path = os.path.join(camera_config.get('target_dir'), filename)
+ size = os.stat(full_path).st_size
+ if request_range:
+ start, end = request_range
+ if (start is not None and start >= size) or end == 0:
+ raise HTTPError(416, 'Range not satisfiable')
+ if start is not None and start < 0:
+ start += size
+ if end is not None and end > size:
+ end = size
+ # Chrome won't allow seeking unless we always return 206 for Range requests,
+ # even if this represents the entire file.
+ self.set_status(206) # Partial Content
+ self.set_header("Content-Range", httputil._get_content_range(start, end, size))
+ else:
+ start = end = None
+
+ if start is not None and end is not None:
+ content_length = end - start
+ elif end is not None:
+ content_length = end
+ elif start is not None:
+ content_length = size - start
+ else:
+ content_length = size
+
pretty_filename = camera_config['@name'] + '_' + os.path.basename(filename)
- self.set_header('Content-Type', 'video/mpeg')
+ self.set_header('Content-Type', mimetypes.guess_type(full_path)[0] or 'video/mpeg')
self.set_header('Content-Disposition', 'attachment; filename=' + pretty_filename + ';')
-
- self.finish(content)
-
+ self.set_header("Content-Length", content_length)
+
+
+ content = StaticFileHandler.get_content(full_path, start, end)
+ if content:
+ for chunk in content:
+ try:
+ self.write(chunk)
+ self.flush()
+ except iostream.StreamClosedError:
+ return
+ self.finish()
+
elif utils.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', 'video/mpeg')
- self.set_header('Content-Disposition', 'attachment; filename=' + pretty_filename + ';')
-
- self.finish(response)
+ self.set_status(response.code)
+ for header in ('Content-Type', 'Content-Range', 'Content-Length', 'Content-Disposition'):
+ if header in response.headers:
+ self.set_header(header, response.headers[header])
+ self.finish(response.body)
- remote.get_media_content(camera_config, filename=filename, media_type='movie', callback=on_response)
+ start = end = None
+ if request_range:
+ start, end = request_range
+ remote.get_media_content(camera_config, filename=filename, media_type='movie', callback=on_response, start=start, end=end)
else: # assuming simple mjpeg camera
raise HTTPError(400, 'unknown operation')
http_client.fetch(request, _callback_wrapper(on_response))
-def get_media_content(local_config, filename, media_type, callback):
+def get_media_content(local_config, filename, media_type, callback, start=None, end=None):
scheme, host, port, username, password, path, camera_id = _remote_params(local_config)
logging.debug('downloading file %(filename)s of remote camera %(id)s on %(url)s' % {
'media_type': media_type,
'id': camera_id,
'filename': filename}
-
+
# timeout here is 10 times larger than usual - we expect a big delay when fetching the media list
request = _make_request(scheme, host, port, username, password,
path, timeout=10 * settings.REMOTE_REQUEST_TIMEOUT)
-
+
+ if start is not None or end is not None:
+ end_str = ''
+ start = start or 0
+ if end:
+ end_str = str(end - 1)
+ request.headers['Range'] = 'bytes=%i-%s' % (start, end_str)
+
def on_response(response):
if response.error:
logging.error('failed to download file %(filename)s of remote camera %(id)s on %(url)s: %(msg)s' % {
return callback(error=utils.pretty_http_error(response))
- return callback(response.body)
+ return callback(response)
http_client = AsyncHTTPClient()
http_client.fetch(request, _callback_wrapper(on_response))
}
div.picture-dialog-prev-arrow,
-div.picture-dialog-next-arrow {
+div.picture-dialog-next-arrow,
+div.picture-dialog-play {
position: absolute;
top: 45%;
background-color: rgba(0, 0, 0, 0.6);
- background-image: url(../img/arrows.svg);
background-size: cover;
width: 3em;
height: 3em;
cursor: pointer;
}
+div.picture-dialog-prev-arrow,
+div.picture-dialog-next-arrow {
+ background-image: url(../img/arrows.svg);
+}
+
+div.picture-dialog-play {
+ background-image: url(../img/camera-top-buttons.svg);
+ background-position: -300% 0px;
+ left: 50%;
+ right: 50%;
+ transform: translate(-1.5em,0);
+}
+
div.picture-dialog-prev-arrow {
left: 1em;
}
var img = $('<img class="picture-dialog-content">');
content.append(img);
-
+
+ var video_container = $('<video class="picture-dialog-content" controls="true">');
+ var video_source = $('<source type="video/mp4">')
+ video_container.append(video_source);
+ video_container.hide()
+ content.append(video_container);
+
var prevArrow = $('<div class="picture-dialog-prev-arrow button mouse-effect" title="previous picture"></div>');
content.append(prevArrow);
-
+
+ var playButton = $('<div class="picture-dialog-play button mouse-effect" title="play"></div>');
+ content.append(playButton);
+
var nextArrow = $('<div class="picture-dialog-next-arrow button mouse-effect" title="next picture"></div>');
content.append(nextArrow);
-
var progressImg = $('<img class="picture-dialog-progress" src="' + staticPath + 'img/modal-progress.gif">');
-
+
function updatePicture() {
var entry = entries[pos];
prevArrow.css('display', 'none');
nextArrow.css('display', 'none');
+
+ var playable = video_container.get(0).canPlayType('video/' + entry.path.split('.').pop()) != ''
+ playButton.hide();
+ video_container.hide();
+ img.show();
+
img.parent().append(progressImg);
updateModalDialogPosition();
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));
+
+ if (playable) {
+ playButton.on('click', function() {
+ video_source.attr('src', addAuthParams('GET', basePath + mediaType + '/' + entry.cameraId + '/download' + entry.path));
+ video_container.show();
+ video_container.get(0).load();
+ img.hide();
+ playButton.hide();
+ video_container.on('canplay', function() {
+ video_container.get(0).play();
+ });
+ });
+
+ playButton.show();
+ }
+
img.load(function () {
var aspectRatio = this.naturalWidth / this.naturalHeight;
var sizeWidth = width * width / aspectRatio;
if (sizeWidth < sizeHeight) {
img.width(width);
+ video_container.width(width);
}
else {
img.height(height);
+ video_container.height(height);
}
updateModalDialogPosition();
prevArrow.css('display', pos > 0 ? '' : 'none');
nextArrow.css('display', pos < entries.length - 1 ? '' : 'none');
progressImg.remove();
});
-
+
$('div.modal-container').find('span.modal-title:last').html(entry.name);
updateModalDialogPosition();
}