]> www.vanbest.org Git - motioneye-debian/commitdiff
implemented zip of pictures download
authorCalin Crisan <ccrisan@gmail.com>
Sat, 27 Sep 2014 17:41:56 +0000 (20:41 +0300)
committerCalin Crisan <ccrisan@gmail.com>
Sun, 28 Sep 2014 08:25:51 +0000 (11:25 +0300)
motioneye.py
settings_default.py
src/handlers.py
src/mediafiles.py
src/remote.py
src/server.py
static/css/main.css
static/js/main.js

index 3fed3df686f7c591a6933348ab17299570ec7545..8448a4e9fc16fd94f4f5a41a9c66d103fca7e3e6 100755 (executable)
@@ -64,6 +64,8 @@ def _configure_settings():
     set_default_setting('ENABLE_REBOOT', False)
     set_default_setting('SMTP_TIMEOUT', 60)
     set_default_setting('NOTIFY_MEDIA_TIMESPAN', 5)
+    set_default_setting('ZIP_TIMEOUT', 500)
+    set_default_setting('TIMELAPSE_TIMEOUT', 500)
 
     length = len(sys.argv) - 1
     for i in xrange(length):
index 294420f73eec5322875740c4c16c02bae0dae82e..4a9e9a20feedaadc76ee93e08f52740debf67d1a 100644 (file)
@@ -79,3 +79,9 @@ SMTP_TIMEOUT = 60
 
 # the interval in seconds to consider around the moment of the event when attaching media files to notifications 
 NOTIFY_MEDIA_TIMESPAN = 5
+
+# the time to wait for zip file creation
+ZIP_TIMEOUT = 500
+
+# the time to wait for timelapse movie file creation
+TIMELAPSE_TIMEOUT = 500
index f65ce0c960db983b63acc3746eb131f8f88dddab..2f4881175b225013eb334d88d1820fd2c9adccbf 100644 (file)
@@ -20,6 +20,7 @@ import datetime
 import json
 import logging
 import os
+import re
 import socket
 
 from tornado.web import RequestHandler, HTTPError, asynchronous
@@ -624,7 +625,7 @@ class ConfigHandler(BaseHandler):
 
 class PictureHandler(BaseHandler):
     @asynchronous
-    def get(self, camera_id, op, filename=None):
+    def get(self, camera_id, op, filename=None, group=None):
         if camera_id is not None:
             camera_id = int(camera_id)
             if camera_id not in config.get_camera_ids():
@@ -645,6 +646,12 @@ class PictureHandler(BaseHandler):
         elif op == 'preview':
             self.preview(camera_id, filename)
         
+        elif op == 'zipped':
+            self.zipped(camera_id, group)
+        
+        elif op == 'timelapse':
+            self.timelapse(camera_id, group)
+        
         else:
             raise HTTPError(400, 'unknown operation')
     
@@ -794,6 +801,124 @@ class PictureHandler(BaseHandler):
                     width=self.get_argument('width', None),
                     height=self.get_argument('height', None))
     
+    @BaseHandler.auth()
+    def zipped(self, camera_id, group):
+        if camera_id not in config.get_camera_ids():
+            raise HTTPError(404, 'no such camera')
+
+        key = self.get_argument('key', None)
+        if key:
+            logging.debug('serving zip file for group %(group)s of camera %(id)s with key %(key)s' % {
+                    'group': group, 'id': camera_id, 'key': key})
+            
+            data = mediafiles.get_prepared_cache(key)
+            if not data:
+                logging.error('prepared cache data for key "%s" does not exist' % key)
+                
+                raise HTTPError(404, 'no such key')
+
+            camera_config = config.get_camera(camera_id)
+            if utils.local_camera(camera_config):
+                pretty_filename = camera_config['@name'] + '_' + group
+                pretty_filename = re.sub('[^a-zA-Z0-9]', '_', pretty_filename)
+     
+            else: # remote camera
+                pretty_filename = re.sub('[^a-zA-Z0-9]', '_', group)
+
+            self.set_header('Content-Type', 'application/zip')
+            self.set_header('Content-Disposition', 'attachment; filename=' + pretty_filename + '.zip;')
+            self.finish(data)
+
+        else:
+            logging.debug('preparing zip file for group %(group)s of camera %(id)s' % {
+                    'group': group, 'id': camera_id})
+
+            camera_config = config.get_camera(camera_id)
+            if utils.local_camera(camera_config):
+                def on_zip(data):
+                    if data is None:
+                        return self.finish_json({'error': 'Failed to create zip file.'})
+    
+                    key = mediafiles.set_prepared_cache(data)
+                    logging.debug('prepared zip file for group %(group)s of camera %(id)s with key %(key)s' % {
+                            'group': group, 'id': camera_id, 'key': key})
+                    self.finish_json({'key': key})
+    
+                mediafiles.get_zipped_content(camera_config, media_type='picture', callback=on_zip, prefix=group)
+    
+            else: # remote camera
+                def on_response(response=None, error=None):
+                    if error:
+                        return self.finish_json({'error': 'Failed to download zip file from %(url)s: %(msg)s.' % {
+                                'url': remote.make_camera_url(camera_config)}, 'msg': error})
+     
+                    key = mediafiles.set_prepared_cache(response)
+                    logging.debug('prepared zip file for group %(group)s of camera %(id)s with key %(key)s' % {
+                            'group': group, 'id': camera_id, 'key': key})
+                    self.finish_json({'key': key})
+    
+                remote.get_zipped_content(camera_config, media_type='picture', callback=on_response, group=group)
+
+    @BaseHandler.auth()
+    def timelapse(self, camera_id, group):
+        if camera_id not in config.get_camera_ids():
+            raise HTTPError(404, 'no such camera')
+
+        key = self.get_argument('key', None)
+        if key:
+            logging.debug('serving timelapse movie for group %(group)s of camera %(id)s with key %(key)s' % {
+                    'group': group, 'id': camera_id, 'key': key})
+            
+            data = mediafiles.get_prepared_cache(key)
+            if not data:
+                logging.error('prepared cache data for key "%s" does not exist' % key)
+                
+                raise HTTPError(404, 'no such key')
+
+            camera_config = config.get_camera(camera_id)
+            if utils.local_camera(camera_config):
+                pretty_filename = camera_config['@name'] + '_' + group
+                pretty_filename = re.sub('[^a-zA-Z0-9]', '_', pretty_filename)
+
+            else: # remote camera
+                pretty_filename = re.sub('[^a-zA-Z0-9]', '_', group)
+
+            self.set_header('Content-Type', 'video/x-msvideo')
+            self.set_header('Content-Disposition', 'attachment; filename=' + pretty_filename + '.avi;')
+            self.finish(data)
+
+        else:
+            interval = int(self.get_argument('interval'))
+
+            logging.debug('preparing timelapse movie for group %(group)s of camera %(id)s with interval %(int)s' % {
+                    'group': group, 'id': camera_id, 'int': interval})
+
+            camera_config = config.get_camera(camera_id)
+            if utils.local_camera(camera_config):
+                def on_timelapse(data):
+                    if data is None:
+                        return self.finish_json({'error': 'Failed to create timelapse movie file.'})
+
+                    key = mediafiles.set_prepared_cache(data)
+                    logging.debug('prepared timelapse movie for group %(group)s of camera %(id)s with key %(key)s' % {
+                            'group': group, 'id': camera_id, 'key': key})
+                    self.finish_json({'key': key})
+
+                mediafiles.get_timelapse_movie(camera_config, interval, callback=on_timelapse, group=group)
+
+            else: # remote camera
+                def on_response(response=None, error=None):
+                    if error:
+                        return self.finish_json({'error': 'Failed to download timelapse movie from %(url)s: %(msg)s.' % {
+                                'url': remote.make_camera_url(camera_config)}, 'msg': error})
+
+                    key = mediafiles.set_prepared_cache(response)
+                    logging.debug('prepared timelapse movie for group %(group)s of camera %(id)s with key %(key)s' % {
+                            'group': group, 'id': camera_id, 'key': key})
+                    self.finish_json({'key': key})
+    
+                remote.get_timelapse_movie(camera_config, interval, callback=on_response, group=group)
+
     def try_finish(self, content):
         try:
             self.finish(content)
index fc79dde385100a1a29c2d11100724b60a9b7307f..38730ff9f1459fbf64f5e64452fa50e14a2a4654 100644 (file)
 # along with this program.  If not, see <http://www.gnu.org/licenses/>. 
 
 import datetime
+import hashlib
 import logging
 import multiprocessing
 import os.path
 import stat
 import StringIO
 import subprocess
+import time
 import tornado
+import zipfile
 
 from PIL import Image
+from tornado import ioloop
 
 import config
 import settings
@@ -41,6 +45,9 @@ _current_pictures_cache = {}
 # a cache list of paths to movies without preview
 _previewless_movie_files = []
 
+# a cache of prepared files (whose preparing time is significant)
+_prepared_files = {}
+
 
 def _list_media_files(dir, exts, prefix=None):
     media_files = []
@@ -306,6 +313,184 @@ def get_media_content(camera_config, path, media_type):
         return None
 
 
+def get_zipped_content(camera_config, media_type, callback, prefix):
+    target_dir = camera_config.get('target_dir')
+
+    if media_type == 'picture':
+        exts = _PICTURE_EXTS
+        
+    elif media_type == 'movie':
+        exts = _MOVIE_EXTS
+        
+    working = multiprocessing.Value('b')
+    working.value = True
+
+    # create a subprocess to add files to zip
+    def do_zip(pipe):
+        mf = _list_media_files(target_dir, exts=exts, prefix=prefix)
+        paths = []
+        for (p, st) in mf:  # @UnusedVariable
+            path = p[len(target_dir):]
+            if path.startswith('/'):
+                path = path[1:]
+
+            paths.append(path)
+            
+        zip_filename = os.path.join(settings.MEDIA_PATH, '.zip-%s' % int(time.time()))
+        logging.debug('adding %d files to zip file "%s"' % (len(paths), zip_filename))
+
+        try:
+            with zipfile.ZipFile(zip_filename, mode='w') as f:
+                for path in paths:
+                    full_path = os.path.join(target_dir, path)
+                    f.write(full_path, path)
+
+        except Exception as e:
+            logging.error('failed to create zip file "%s": %s' % (zip_filename, e))
+
+            working.value = False
+            pipe.close()
+            return
+
+        logging.debug('reading zip file "%s" into memory' % zip_filename)
+
+        try:
+            with open(zip_filename, mode='r') as f:
+                data = f.read()
+
+            working.value = False
+            pipe.send(data)
+            logging.debug('zip data ready')
+
+        except Exception as e:
+            logging.error('failed to read zip file "%s": %s' % (zip_filename, e))
+            working.value = False
+
+        finally:
+            os.remove(zip_filename)
+            pipe.close()
+
+    logging.debug('starting zip process...')
+
+    (parent_pipe, child_pipe) = multiprocessing.Pipe(duplex=False)
+    process = multiprocessing.Process(target=do_zip, args=(child_pipe, ))
+    process.start()
+
+    # poll the subprocess to see when it has finished
+    started = datetime.datetime.now()
+
+    def poll_process():
+        ioloop = tornado.ioloop.IOLoop.instance()
+        if working.value:
+            now = datetime.datetime.now()
+            delta = now - started
+            if delta.seconds < settings.ZIP_TIMEOUT:
+                ioloop.add_timeout(datetime.timedelta(seconds=0.5), poll_process)
+
+            else: # process did not finish within 2 minutes
+                logging.error('timeout waiting for the zip process to finish')
+
+                callback(None)
+
+        else: # finished
+            try:
+                data = parent_pipe.recv()
+                logging.debug('zip process has returned %d bytes' % len(data))
+                
+            except:
+                data = None
+            
+            callback(data)
+
+    poll_process()
+
+
+def get_timelapse_movie(camera_config, interval, callback, prefix):
+    target_dir = camera_config.get('target_dir')
+
+#     working = multiprocessing.Value('b')
+#     working.value = True
+
+#     # create a subprocess to create the files
+#     def do_zip(pipe):
+#         mf = _list_media_files(target_dir, exts=exts, prefix=prefix)
+#         paths = []
+#         for (p, st) in mf:  # @UnusedVariable
+#             path = p[len(target_dir):]
+#             if path.startswith('/'):
+#                 path = path[1:]
+# 
+#             paths.append(path)
+#             
+#         zip_filename = os.path.join(settings.MEDIA_PATH, '.zip-%s' % int(time.time()))
+#         logging.debug('adding %d files to zip file "%s"' % (len(paths), zip_filename))
+# 
+#         try:
+#             with zipfile.ZipFile(zip_filename, mode='w') as f:
+#                 for path in paths:
+#                     full_path = os.path.join(target_dir, path)
+#                     f.write(full_path, path)
+# 
+#         except Exception as e:
+#             logging.error('failed to create zip file "%s": %s' % (zip_filename, e))
+# 
+#             working.value = False
+#             pipe.close()
+#             return
+# 
+#         logging.debug('reading zip file "%s" into memory' % zip_filename)
+# 
+#         try:
+#             with open(zip_filename, mode='r') as f:
+#                 data = f.read()
+# 
+#             working.value = False
+#             pipe.send(data)
+#             logging.debug('zip data ready')
+# 
+#         except Exception as e:
+#             logging.error('failed to read zip file "%s": %s' % (zip_filename, e))
+#             working.value = False
+# 
+#         finally:
+#             os.remove(zip_filename)
+#             pipe.close()
+# 
+#     logging.debug('starting zip process...')
+# 
+#     (parent_pipe, child_pipe) = multiprocessing.Pipe(duplex=False)
+#     process = multiprocessing.Process(target=do_zip, args=(child_pipe, ))
+#     process.start()
+# 
+#     # poll the subprocess to see when it has finished
+#     started = datetime.datetime.now()
+# 
+#     def poll_process():
+#         ioloop = tornado.ioloop.IOLoop.instance()
+#         if working.value:
+#             now = datetime.datetime.now()
+#             delta = now - started
+#             if delta.seconds < settings.ZIP_TIMEOUT:
+#                 ioloop.add_timeout(datetime.timedelta(seconds=0.5), poll_process)
+# 
+#             else: # process did not finish within 2 minutes
+#                 logging.error('timeout waiting for the zip process to finish')
+# 
+#                 callback(None)
+# 
+#         else: # finished
+#             try:
+#                 data = parent_pipe.recv()
+#                 logging.debug('zip process has returned %d bytes' % len(data))
+#                 
+#             except:
+#                 data = None
+#             
+#             callback(data)
+# 
+#     poll_process()
+
+
 def get_media_preview(camera_config, path, media_type, width, height):
     target_dir = camera_config.get('target_dir')
     full_path = os.path.join(target_dir, path)
@@ -379,8 +564,6 @@ def get_current_picture(camera_config, width, height):
 
 
 def set_picture_cache(camera_id, sequence, width, content):
-    global _current_pictures_cache
-    
     if not content:
         return
     
@@ -393,8 +576,6 @@ def set_picture_cache(camera_id, sequence, width, content):
 
 
 def get_picture_cache(camera_id, sequence, width):
-    global _current_pictures_cache
-    
     cache = _current_pictures_cache.setdefault(camera_id, [])
     now = datetime.datetime.utcnow()
 
@@ -407,3 +588,25 @@ def get_picture_cache(camera_id, sequence, width):
             return content
         
     return None
+
+
+def get_prepared_cache(key):
+    return _prepared_files.get(key)
+
+
+def set_prepared_cache(data):
+    key = hashlib.sha1(str(time.time())).hexdigest()
+
+    if key in _prepared_files:
+        logging.warn('key "%s" already present in prepared cache' % key)
+        
+    _prepared_files[key] = data
+    
+    def clear():
+        if _prepared_files.pop(key, None) is not None:
+            logging.warn('key "%s" was still present in the prepared cache, removed' % key)
+
+    timeout = max(settings.ZIP_TIMEOUT, settings.TIMELAPSE_TIMEOUT)
+    ioloop.IOLoop.instance().add_timeout(datetime.timedelta(seconds=timeout), clear)
+
+    return key
index 8bc29a611b0a7b2f7d1abf5510ac40c4ee0e4ee0..466f1e56d1f27ae7cb864a16ddc07b2b0aaefa50 100644 (file)
@@ -333,6 +333,145 @@ def get_media_content(local_config, callback, filename, media_type):
     http_client.fetch(request, on_response)
 
 
+def get_zipped_content(local_config, callback, group, media_type):
+    host = local_config.get('@host', local_config.get('host'))
+    port = local_config.get('@port', local_config.get('port'))
+    username = local_config.get('@username', local_config.get('username'))
+    password = local_config.get('@password', local_config.get('password'))
+    uri = local_config.get('@uri', local_config.get('uri')) or ''
+    camera_id = local_config.get('@remote_camera_id', local_config.get('remote_camera_id'))
+    
+    logging.debug('downloading zip file for group %(group)s of remote camera %(id)s on %(url)s' % {
+            'group': group,
+            'id': camera_id,
+            'url': make_camera_url(local_config)})
+
+    prepare_uri = uri + '/%(media_type)s/%(id)s/zipped/%(group)s/' % {
+            'media_type': media_type,
+            'id': camera_id,
+            'group': group}
+    # timeout here is 100 times larger than usual - we expect a big delay
+    request = _make_request(host, port, username, password, prepare_uri, timeout=100 * settings.REMOTE_REQUEST_TIMEOUT)
+
+    def on_prepare(response):
+        if response.error:
+            logging.error('failed to download zip file for group %(group)s of remote camera %(id)s on %(url)s: %(msg)s' % {
+                    'group': group,
+                    'id': camera_id,
+                    'url': make_camera_url(local_config),
+                    'msg': unicode(response.error)})
+
+            return callback(error=unicode(response.error))
+        
+        try:
+            key = json.loads(response.body)['key']
+            
+        except Exception as e:
+            logging.error('failed to decode json answer from %(url)s: %(msg)s' % {
+                    'url': make_camera_url(local_config),
+                    'msg': unicode(e)})
+
+            return callback(error=unicode(e))
+
+        download_uri = uri + '/%(media_type)s/%(id)s/zipped/%(group)s/?key=%(key)s' % {
+                'media_type': media_type,
+                'id': camera_id,
+                'group': group,
+                'key': key}
+
+        request = _make_request(host, port, username, password, download_uri, timeout=100 * settings.REMOTE_REQUEST_TIMEOUT)
+
+        def on_download(response):
+            if response.error:
+                logging.error('failed to download zip file for group %(group)s of remote camera %(id)s on %(url)s: %(msg)s' % {
+                        'group': group,
+                        'id': camera_id,
+                        'url': make_camera_url(local_config),
+                        'msg': unicode(response.error)})
+    
+                return callback(error=unicode(response.error))
+
+            callback(response.body)
+
+        http_client = AsyncHTTPClient()
+        http_client.fetch(request, on_download)
+
+    http_client = AsyncHTTPClient()
+    http_client.fetch(request, on_prepare)
+
+
+def get_timelapse_movie(local_config, interval, callback, group):
+    host = local_config.get('@host', local_config.get('host'))
+    port = local_config.get('@port', local_config.get('port'))
+    username = local_config.get('@username', local_config.get('username'))
+    password = local_config.get('@password', local_config.get('password'))
+    uri = local_config.get('@uri', local_config.get('uri')) or ''
+    camera_id = local_config.get('@remote_camera_id', local_config.get('remote_camera_id'))
+
+    logging.debug('downloading timelapse movie for group %(group)s of remote camera %(id)s with interval %(int)s on %(url)s' % {
+            'group': group,
+            'id': camera_id,
+            'int': interval,
+            'url': make_camera_url(local_config)})
+
+    prepare_uri = uri + '/picture/%(id)s/timelapse/%(group)s/?interval=%(int)s' % {
+            'id': camera_id,
+            'int': interval,
+            'group': group}
+
+    # timeout here is 100 times larger than usual - we expect a big delay
+    request = _make_request(host, port, username, password, prepare_uri, timeout=100 * settings.REMOTE_REQUEST_TIMEOUT)
+
+    def on_prepare(response):
+        if response.error:
+            logging.error('failed to download timelapse movie for group %(group)s of remote camera %(id)s with interval %(int)s on %(url)s: %(msg)s' % {
+                    'group': group,
+                    'id': camera_id,
+                    'url': make_camera_url(local_config),
+                    'int': interval,
+                    'msg': unicode(response.error)})
+
+            return callback(error=unicode(response.error))
+        
+        try:
+            key = json.loads(response.body)['key']
+            
+        except Exception as e:
+            logging.error('failed to decode json answer from %(url)s: %(msg)s' % {
+                    'url': make_camera_url(local_config),
+                    'msg': unicode(e)})
+
+            return callback(error=unicode(e))
+
+        download_uri = uri + '/picture/%(id)s/timelapse/%(group)s/?key=%(key)s' % {
+                'id': camera_id,
+                'group': group,
+                'int': interval,
+                'key': key}
+
+        request = _make_request(host, port, username, password, download_uri, timeout=100 * settings.REMOTE_REQUEST_TIMEOUT)
+
+        def on_download(response):
+            if response.error:
+                logging.error('failed to download timelapse movie for group %(group)s of remote camera %(id)s with interval %(int)s on %(url)s: %(msg)s' % {
+                        'group': group,
+                        'id': camera_id,
+                        'url': make_camera_url(local_config),
+                        'int': interval,
+                        'msg': unicode(response.error)})
+    
+                return callback(error=unicode(response.error))
+
+            callback(response.body)
+
+        http_client = AsyncHTTPClient()
+        http_client.fetch(request, on_download)
+
+    http_client = AsyncHTTPClient()
+    http_client.fetch(request, on_prepare)
+
+
 def get_media_preview(local_config, callback, filename, media_type, width, height):
     host = local_config.get('@host', local_config.get('host'))
     port = local_config.get('@port', local_config.get('port'))
index 8df6d92091fd50162d007320a4b7763232fb2b99..f42758c0f2cfea8a3a4955e5ecc6a180e84eb52f 100644 (file)
@@ -45,9 +45,10 @@ application = Application(
         (r'^/config/(?P<camera_id>\d+)/(?P<op>get|set|rem|set_preview)/?$', handlers.ConfigHandler),
         (r'^/config/(?P<op>add|list|list_devices)/?$', handlers.ConfigHandler),
         (r'^/picture/(?P<camera_id>\d+)/(?P<op>current|list|frame)/?$', handlers.PictureHandler),
-        (r'^/picture/(?P<camera_id>\d+)/(?P<op>download|preview)/(?P<filename>.+)/?$', handlers.PictureHandler),
+        (r'^/picture/(?P<camera_id>\d+)/(?P<op>download|preview)/(?P<filename>.+?)/?$', handlers.PictureHandler),
+        (r'^/picture/(?P<camera_id>\d+)/(?P<op>zipped|timelapse)/(?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)/(?P<filename>.+)/?$', handlers.MovieHandler),
+        (r'^/movie/(?P<camera_id>\d+)/(?P<op>download|preview)/(?P<filename>.+?)/?$', handlers.MovieHandler),
         (r'^/update/?$', handlers.UpdateHandler),
         (r'^/power/(?P<op>shutdown)/?$', handlers.PowerHandler),
         (r'^/version/?$', handlers.VersionHandler),
index e9f9f65de28b2a937224e7ae66d04b4eae896927..b223588905aaab920353ce7f27c6dbe5948adb7b 100644 (file)
@@ -601,6 +601,33 @@ div.media-list-download-button:ACTIVE {
     background-color: #317CAD;
 }
 
+div.media-dialog-buttons {
+    margin: 0.5em 0px 0px 0px;
+    text-align: center;
+}
+
+div.media-dialog-button {
+    cursor: pointer;
+    display: inline-block;
+    height: 1.5em;
+    line-height: 1.5em;
+    text-align: center;
+    padding: 0px 0.5em;
+    margin: 0px 5px 0px 0px;
+    color: white;
+    background-color: #317CAD;
+    border-radius: 3px;
+    transition: all 0.1s linear;
+}
+
+div.media-dialog-button:HOVER {
+    background-color: #3498db;
+}
+
+div.media-dialog-button:ACTIVE {
+    background-color: #317CAD;
+}
+
 div.picture-dialog-content {
     position: relative;
     text-align: center;
@@ -644,6 +671,10 @@ img.picture-dialog-progress {
     opacity: 0.7;
 }
 
+table.timelapse-dialog select#intervalSelect {
+    width: 10em;
+}
+
 
     /* camera frames */
 
index 708200a9a933b7577a194db1898cef0fc07542b9..c16b3754c422701d445346671832468b7ac4a3f9 100644 (file)
@@ -1073,7 +1073,7 @@ function endProgress() {
     }, 500);
 }
 
-function downloadMediaFile(uri) {
+function downloadFile(uri) {
     var url = window.location.href;
     var parts = url.split('/');
     url = parts.slice(0, 3).join('/') + uri;
@@ -1304,6 +1304,14 @@ function doUpdate() {
     });
 }
 
+function doDownloadZipped(cameraId, groupKey) {
+    showModalDialog('<div class="modal-progress"></div>', null, null, true);
+    ajax('GET', '/picture/' + cameraId + '/zipped/' + groupKey + '/', null, function (data) {
+        hideModalDialog(); /* progress */
+        downloadFile('/picture/' + cameraId + '/zipped/' + groupKey + '/?key=' + data.key);
+    });
+}
+
 
     /* fetch & push */
 
@@ -1581,7 +1589,7 @@ function runPictureDialog(entries, pos, mediaType) {
             {caption: 'Close'},
             {caption: 'Download', isDefault: true, click: function () {
                 var entry = entries[pos];
-                downloadMediaFile('/' + mediaType + '/' + entry.cameraId + '/download' + entry.path);
+                downloadFile('/' + mediaType + '/' + entry.cameraId + '/download' + entry.path);
                 
                 return false;
             }}
@@ -1839,15 +1847,87 @@ function runAddCameraDialog() {
     });
 }
 
+function runTimelapseDialog(cameraId, groupKey, group) {
+    var content = 
+            $('<table class="timelapse-dialog">' +
+                '<tr>' +
+                    '<td class="dialog-item-label"><span class="dialog-item-label">Group</span></td>' +
+                    '<td class="dialog-item-value">' + groupKey + '</td>' +
+                '</tr>' +
+                '<tr>' +
+                    '<td class="dialog-item-label"><span class="dialog-item-label">Include a picture every</span></td>' +
+                    '<td class="dialog-item-value">' +
+                        '<select class="styled timelapse" id="intervalSelect">' + 
+                            '<option value="1">second</option>' +
+                            '<option value="10">5 seconds</option>' +
+                            '<option value="10">10 seconds</option>' +
+                            '<option value="30">30 seconds</option>' +
+                            '<option value="60">minute</option>' +
+                            '<option value="300">5 minutes</option>' +
+                            '<option value="600">10 minutes</option>' +
+                            '<option value="1800">30 minutes</option>' +
+                            '<option value="3600">hour</option>' +
+                        '</select>' +
+                    '</td>' +
+                    '<td><span class="help-mark" title="select the interval of time between each two successive pictures included in the movie">?</span></td>' +
+                '</tr>' +
+            '</table>');
+
+    var intervalSelect = content.find('#intervalSelect');
+    
+    runModalDialog({
+        title: 'Create Timelapse Movie',
+        closeButton: true,
+        buttons: 'okcancel',
+        content: content,
+        onOk: function () {
+            showModalDialog('<div class="modal-progress"></div>', null, null, true);
+            ajax('GET', '/picture/' + cameraId + '/timelapse/' + groupKey + '/', {interval: intervalSelect.val()}, function (data) {
+                hideModalDialog(); /* progress */
+                hideModalDialog(); /* timelapse dialog */
+                downloadFile('/picture/' + cameraId + '/timelapse/' + groupKey + '/key=' + data.key);
+            });
+
+            return false;
+        },
+        stack: true
+    });
+}
 
 function runMediaDialog(cameraId, mediaType) {
     var dialogDiv = $('<div class="media-dialog"></div>');
     var mediaListDiv = $('<div class="media-dialog-list"></div>');
     var groupsDiv = $('<div class="media-dialog-groups"></div>');
     
+    var groups = {};
+    var groupKey = null;
+    
     dialogDiv.append(groupsDiv);
     dialogDiv.append(mediaListDiv);
     
+    if (mediaType == 'picture') {
+        var buttonsDiv = $('<div class="media-dialog-buttons"></div>');
+        dialogDiv.append(buttonsDiv);
+        
+        var zippedButton = $('<div class="media-dialog-button">Zipped Pictures</div>');
+        buttonsDiv.append(zippedButton);
+        
+        zippedButton.click(function () {
+            if (groupKey) {
+                doDownloadZipped(cameraId, groupKey);
+            }
+        });
+        
+        var timelapseButton = $('<div class="media-dialog-button">Timelapse Movie</div>');
+        buttonsDiv.append(timelapseButton);
+        
+        timelapseButton.click(function () {
+            if (groupKey) {
+                runTimelapseDialog(cameraId, groupKey, groups[groupKey]);
+            }
+        });
+    }
+    
     function setDialogSize() {
         var windowWidth = $(window).width();
         var windowHeight = $(window).height();
@@ -1888,7 +1968,6 @@ function runMediaDialog(cameraId, mediaType) {
         }
         
         /* group the media */
-        var groups = {};
         data.mediaList.forEach(function (media) {
             var path = media.path;
             var parts = path.split('/');
@@ -1920,6 +1999,8 @@ function runMediaDialog(cameraId, mediaType) {
         tempDiv.remove();
 
         function showGroup(key) {
+            groupKey = key;
+            
             if (mediaListDiv.find('img.media-list-progress').length) {
                 return; /* already in progress of loading */
             }
@@ -1955,7 +2036,7 @@ function runMediaDialog(cameraId, mediaType) {
                         entryDiv.append(previewImg);
                         previewImg[0]._src = '/' + mediaType + '/' + cameraId + '/preview' + entry.path + '?height=' + height;
                         
-                        var downloadButton = $('<div class="media-list-download-button button">download</div>');
+                        var downloadButton = $('<div class="media-list-download-button button">Download</div>');
                         entryDiv.append(downloadButton);
                         
                         var nameDiv = $('<div class="media-list-entry-name">' + entry.name + '</div>');
@@ -1965,7 +2046,7 @@ function runMediaDialog(cameraId, mediaType) {
                         entryDiv.append(detailsDiv);
                         
                         downloadButton.click(function () {
-                            downloadMediaFile('/picture/' + cameraId + '/download' + entry.path);
+                            downloadFile('/picture/' + cameraId + '/download' + entry.path);
                             return false;
                         });