From 387c185f5c6231008c738fda79413ab82d7822ca Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Sun, 28 Dec 2014 18:41:42 +0200 Subject: [PATCH] major refactoring of timelapse feature, zipped download and remote motioneye cameras --- eventrelay.py | 11 +- motioneye.py | 7 +- settings_default.py | 3 - src/handlers.py | 210 ++++++++++++++++++++++++------------- src/mediafiles.py | 101 +++++++++++++----- src/remote.py | 250 ++++++++++++++++++++++++++++++-------------- src/utils.py | 2 +- static/css/main.css | 37 ++----- static/css/ui.css | 29 +++++ static/js/main.js | 109 +++++++++++++++---- static/js/ui.js | 27 ++++- templates/main.html | 5 +- 12 files changed, 549 insertions(+), 242 deletions(-) diff --git a/eventrelay.py b/eventrelay.py index 8d66bb7..ca53da0 100755 --- a/eventrelay.py +++ b/eventrelay.py @@ -18,6 +18,7 @@ import errno import hashlib +import json import logging import os.path import sys @@ -122,18 +123,22 @@ if __name__ == '__main__': admin_username, admin_password = get_admin_credentials() - uri = '/config/%(camera_id)s/_relay_event/?event=%(event)s&username=%(username)s' % { + uri = '/config/%(camera_id)s/_relay_event/?event=%(event)s&_username=%(username)s' % { 'username': admin_username, 'camera_id': camera_id, 'event': event} signature = compute_signature('POST', uri, '', admin_password) - url = 'http://127.0.0.1:%(port)s' + uri + '&signature=' + signature + url = 'http://127.0.0.1:%(port)s' + uri + '&_signature=' + signature url = url % {'port': settings.PORT} try: - urllib.urlopen(url, data='') + response = urllib.urlopen(url, data='') + response = json.load(response) + if response.get('error'): + raise Exception(response['error']) + logging.debug('event successfully relayed') except Exception as e: diff --git a/motioneye.py b/motioneye.py index 6b28b8e..508e6ad 100755 --- a/motioneye.py +++ b/motioneye.py @@ -65,7 +65,6 @@ def _configure_settings(): 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): @@ -150,9 +149,9 @@ def _test_requirements(): print('SMB_SHARES require root privileges') return False -# if settings.ENABLE_REBOOT: -# print('reboot requires root privileges') -# return False + if settings.ENABLE_REBOOT: + print('reboot requires root privileges') + return False try: import tornado # @UnusedImport diff --git a/settings_default.py b/settings_default.py index 4a9e9a2..253893e 100644 --- a/settings_default.py +++ b/settings_default.py @@ -82,6 +82,3 @@ 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 diff --git a/src/handlers.py b/src/handlers.py index 9bf912a..e2db4b6 100644 --- a/src/handlers.py +++ b/src/handlers.py @@ -69,8 +69,8 @@ class BaseHandler(RequestHandler): def get_current_user(self): main_config = config.get_main() - username = self.get_argument('username', None) - signature = self.get_argument('signature', None) + username = self.get_argument('_username', None) + signature = self.get_argument('_signature', None) if (username == main_config.get('@admin_username') and signature == utils.compute_signature(self.request.method, self.request.uri, self.request.body, main_config.get('@admin_password'))): @@ -108,8 +108,10 @@ class BaseHandler(RequestHandler): def auth(admin=False, prompt=True): def decorator(func): def wrapper(self, *args, **kwargs): + _admin = self.get_argument('_admin', None) == 'true' + user = self.current_user - if (user is None) or (user != 'admin' and admin): + if (user is None) or (user != 'admin' and (admin or _admin)): self.set_header('Content-Type', 'application/json') return self.finish_json({'error': 'unauthorized', 'prompt': prompt}) @@ -593,7 +595,7 @@ class ConfigHandler(BaseHandler): else: # remote camera def on_response(remote_ui_config=None, error=None): if error: - self.finish_json({'error': error}) + return self.finish_json({'error': error}) for key, value in camera_config.items(): remote_ui_config[key.replace('@', '')] = value @@ -725,7 +727,7 @@ class PictureHandler(BaseHandler): self.set_cookie('motion_detected_' + str(camera_id), str(motion_detected).lower()) self.try_finish(picture) - remote.get_current_picture(camera_config, on_response, width=width, height=height) + remote.get_current_picture(camera_config, width=width, height=height, callback=on_response) @BaseHandler.auth() def list(self, camera_id): @@ -753,7 +755,7 @@ class PictureHandler(BaseHandler): self.finish_json(remote_list) - remote.list_media(camera_config, on_response, media_type='picture', prefix=self.get_argument('prefix', None)) + remote.list_media(camera_config, media_type='picture', prefix=self.get_argument('prefix', None), callback=on_response) @BaseHandler.auth() def frame(self, camera_id): @@ -809,7 +811,7 @@ class PictureHandler(BaseHandler): self.finish(response) - remote.get_media_content(camera_config, on_response, filename=filename, media_type='picture') + remote.get_media_content(camera_config, filename=filename, media_type='picture', callback=on_response) @BaseHandler.auth() def preview(self, camera_id, filename): @@ -842,9 +844,10 @@ class PictureHandler(BaseHandler): self.finish(content) - remote.get_media_preview(camera_config, on_response, filename=filename, media_type='picture', + remote.get_media_preview(camera_config, filename=filename, media_type='picture', width=self.get_argument('width', None), - height=self.get_argument('height', None)) + height=self.get_argument('height', None), + callback=on_response) @BaseHandler.auth(admin=True) def delete(self, camera_id, filename): @@ -868,38 +871,52 @@ class PictureHandler(BaseHandler): self.finish_json() - remote.del_media_content(camera_config, on_response, filename=filename, media_type='picture') + remote.del_media_content(camera_config, filename=filename, media_type='picture', callback=on_response) @BaseHandler.auth() def zipped(self, camera_id, group): key = self.get_argument('key', None) + camera_config = config.get_camera(camera_id) + 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) - + 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: # remote camera - pretty_filename = re.sub('[^a-zA-Z0-9]', '_', group) + 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}}) - self.set_header('Content-Type', 'application/zip') - self.set_header('Content-Disposition', 'attachment; filename=' + pretty_filename + '.zip;') - self.finish(data) + self.set_header('Content-Type', response['content_type']) + self.set_header('Content-Disposition', response['content_disposition']) + self.finish(response['data']) - else: + remote.get_zipped_content(camera_config, media_type='picture', key=key, callback=on_response) + + else: # prepare 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: @@ -910,78 +927,124 @@ class PictureHandler(BaseHandler): 'group': group, 'id': camera_id, 'key': key}) self.finish_json({'key': key}) - mediafiles.get_zipped_content(camera_config, media_type='picture', callback=on_zip, group=group) + mediafiles.get_zipped_content(camera_config, media_type='picture', group=group, callback=on_zip) 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.' % { + return self.finish_json({'error': 'Failed to make zip file at %(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) + self.finish_json({'key': response['key']}) + + remote.make_zipped_content(camera_config, media_type='picture', group=group, callback=on_response) @BaseHandler.auth() def timelapse(self, camera_id, group): key = self.get_argument('key', None) - if key: + check = self.get_argument('check', False) + camera_config = config.get_camera(camera_id) + + if key: # download 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') + if utils.local_camera(camera_config): + data = mediafiles.get_prepared_cache(key) + if data is None: + 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: # 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}}) + + self.set_header('Content-Type', response['content_type']) + self.set_header('Content-Disposition', response['content_disposition']) + self.finish(response['data']) + + remote.get_timelapse_movie(camera_config, key, callback=on_response) + + elif check: + logging.debug('checking timelapse movie status 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): - pretty_filename = camera_config['@name'] + '_' + group - pretty_filename = re.sub('[^a-zA-Z0-9]', '_', pretty_filename) + status = mediafiles.check_timelapse_movie() + if status['progress'] == -1 and status['data']: + key = mediafiles.set_prepared_cache(status['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, 'progress': -1}) + + else: + self.finish_json(status) else: # remote camera - pretty_filename = re.sub('[^a-zA-Z0-9]', '_', group) + def on_response(response=None, error=None): + if error: + return self.finish_json({'error': 'Failed to check timelapse movie progress at %(url)s: %(msg)s.' % { + 'url': remote.make_camera_url(camera_config), 'msg': error}}) - self.set_header('Content-Type', 'video/x-msvideo') - self.set_header('Content-Disposition', 'attachment; filename=' + pretty_filename + '.avi;') - self.finish(data) + if response['progress'] == -1 and response.get('key'): + self.finish_json({'key': response['key'], 'progress': -1}) + + else: + self.finish_json(response) - else: + remote.check_timelapse_movie(camera_config, callback=on_response) + + else: # start timelapse interval = int(self.get_argument('interval')) framerate = int(self.get_argument('framerate')) logging.debug('preparing timelapse movie for group %(group)s of camera %(id)s with rate %(framerate)s/%(int)s' % { 'group': group, 'id': camera_id, 'framerate': framerate, '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.'}) + status = mediafiles.check_timelapse_movie() + if status['progress'] != -1: + self.finish_json({'progress': status['progress']}) # timelapse already active - 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, framerate, interval, callback=on_timelapse, group=group) + else: + mediafiles.make_timelapse_movie(camera_config, framerate, interval, group=group) + self.finish_json({'progress': -1}) else: # remote camera - def on_response(response=None, error=None): + def on_status(response=None, error=None): if error: - return self.finish_json({'error': 'Failed to download timelapse movie from %(url)s: %(msg)s.' % { + return self.finish_json({'error': 'Failed to make timelapse movie at %(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}) + + if response['progress'] != -1: + return self.finish_json({'progress': response['progress']}) # timelapse already active + + def on_make(response=None, error=None): + if error: + return self.finish_json({'error': 'Failed to make timelapse movie at %(url)s: %(msg)s.' % { + 'url': remote.make_camera_url(camera_config), 'msg': error}}) - remote.get_timelapse_movie(camera_config, framerate, interval, callback=on_response, group=group) + self.finish_json({'progress': -1}) + + remote.make_timelapse_movie(camera_config, framerate, interval, group=group, callback=on_make) + + remote.check_timelapse_movie(camera_config, callback=on_status) + @BaseHandler.auth(admin=True) def delete_all(self, camera_id, group): @@ -1005,7 +1068,7 @@ class PictureHandler(BaseHandler): self.finish_json() - remote.del_media_group(camera_config, on_response, group=group, media_type='picture') + remote.del_media_group(camera_config, group=group, media_type='picture', callback=on_response) def try_finish(self, content): try: @@ -1077,7 +1140,7 @@ class MovieHandler(BaseHandler): self.finish_json(remote_list) - remote.list_media(camera_config, on_response, media_type='movie', prefix=self.get_argument('prefix', None)) + remote.list_media(camera_config, media_type='movie', prefix=self.get_argument('prefix', None), callback=on_response) @BaseHandler.auth() def download(self, camera_id, filename): @@ -1106,7 +1169,7 @@ class MovieHandler(BaseHandler): self.finish(response) - remote.get_media_content(camera_config, on_response, filename=filename, media_type='movie') + remote.get_media_content(camera_config, filename=filename, media_type='movie', callback=on_response) @BaseHandler.auth() def preview(self, camera_id, filename): @@ -1139,9 +1202,10 @@ class MovieHandler(BaseHandler): self.finish(content) - remote.get_media_preview(camera_config, on_response, filename=filename, media_type='movie', + remote.get_media_preview(camera_config, filename=filename, media_type='movie', width=self.get_argument('width', None), - height=self.get_argument('height', None)) + height=self.get_argument('height', None), + callback=on_response) @BaseHandler.auth(admin=True) def delete(self, camera_id, filename): @@ -1165,7 +1229,7 @@ class MovieHandler(BaseHandler): self.finish_json() - remote.del_media_content(camera_config, on_response, filename=filename, media_type='movie') + remote.del_media_content(camera_config, filename=filename, media_type='movie', callback=on_response) @BaseHandler.auth(admin=True) def delete_all(self, camera_id, group): @@ -1189,7 +1253,7 @@ class MovieHandler(BaseHandler): self.finish_json() - remote.del_media_group(camera_config, on_response, group=group, media_type='movie') + remote.del_media_group(camera_config, group=group, media_type='movie', callback=on_response) class UpdateHandler(BaseHandler): diff --git a/src/mediafiles.py b/src/mediafiles.py index b03ed36..8004d35 100644 --- a/src/mediafiles.py +++ b/src/mediafiles.py @@ -16,10 +16,14 @@ # along with this program. If not, see . import datetime +import errno +import fcntl +import functools import hashlib import logging import multiprocessing import os.path +import re import stat import StringIO import subprocess @@ -48,6 +52,9 @@ _previewless_movie_files = [] # a cache of prepared files (whose preparing time is significant) _prepared_files = {} +_timelapse_process = None +_timelapse_data = None + def _list_media_files(dir, exts, prefix=None): media_files = [] @@ -313,7 +320,7 @@ def get_media_content(camera_config, path, media_type): return None -def get_zipped_content(camera_config, media_type, callback, group): +def get_zipped_content(camera_config, media_type, group, callback): target_dir = camera_config.get('target_dir') if media_type == 'picture': @@ -405,7 +412,10 @@ def get_zipped_content(camera_config, media_type, callback, group): poll_process() -def get_timelapse_movie(camera_config, framerate, interval, callback, group): +def make_timelapse_movie(camera_config, framerate, interval, group): + global _timelapse_process + global _timelapse_data + target_dir = camera_config.get('target_dir') # create a subprocess to retrieve media files @@ -424,8 +434,9 @@ def get_timelapse_movie(camera_config, framerate, interval, callback, group): logging.debug('starting media listing process...') (parent_pipe, child_pipe) = multiprocessing.Pipe(duplex=False) - process = [multiprocessing.Process(target=do_list_media, args=(child_pipe, ))] - process[0].start() + _timelapse_process = multiprocessing.Process(target=do_list_media, args=(child_pipe, )) + _timelapse_process.progress = 0 + _timelapse_process.start() started = [datetime.datetime.now()] media_list = [] @@ -438,24 +449,26 @@ def get_timelapse_movie(camera_config, framerate, interval, callback, group): def poll_media_list_process(): ioloop = tornado.ioloop.IOLoop.instance() - if process[0].is_alive(): # not finished yet + if _timelapse_process.is_alive(): # not finished yet now = datetime.datetime.now() delta = now - started[0] - if delta.seconds < 120: + if delta.seconds < 300: # the subprocess has 5 minutes to complete its job ioloop.add_timeout(datetime.timedelta(seconds=0.5), poll_media_list_process) read_media_list() else: # process did not finish within 2 minutes logging.error('timeout waiting for the media listing process to finish') - callback(None) + _timelapse_process.progress = -1 else: # finished read_media_list() logging.debug('media listing process has returned %(count)s files' % {'count': len(media_list)}) if not media_list: - return callback(None) + _timelapse_process.progress = -1 + + return pictures = select_pictures(media_list) make_movie(pictures) @@ -484,59 +497,89 @@ def get_timelapse_movie(camera_config, framerate, interval, callback, group): return selected def make_movie(pictures): + global _timelapse_process + cmd = 'rm -f %(tmp_filename)s;' cmd += 'cat %(jpegs)s | ffmpeg -framerate %(framerate)s -f image2pipe -vcodec mjpeg -i - -vcodec mpeg4 -b:v %(bitrate)s -q:v 0 -f avi %(tmp_filename)s' - + bitrate = 9999999 cmd = cmd % { 'tmp_filename': tmp_filename, - 'jpegs': ' '.join((p['path'] for p in pictures)), + 'jpegs': ' '.join((('"' + p['path'] + '"') for p in pictures)), 'framerate': framerate, 'bitrate': bitrate } logging.debug('executing "%s"' % cmd) - process[0] = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=None, shell=True) - started[0] = datetime.datetime.now() + _timelapse_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True) + _timelapse_process.progress = 0.01 # 1% + + # make subprocess stdout pipe non-blocking + fd = _timelapse_process.stdout.fileno() + fl = fcntl.fcntl(fd, fcntl.F_GETFL) + fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) - poll_movie_process() + poll_movie_process(pictures) - def poll_movie_process(): + def poll_movie_process(pictures): + global _timelapse_process + global _timelapse_data + ioloop = tornado.ioloop.IOLoop.instance() - if process[0].poll() is None: # not finished yet - now = datetime.datetime.now() - delta = now - started[0] - if delta.seconds < 120: - ioloop.add_timeout(datetime.timedelta(seconds=0.5), poll_movie_process) + if _timelapse_process.poll() is None: # not finished yet + ioloop.add_timeout(datetime.timedelta(seconds=0.5), functools.partial(poll_movie_process, pictures)) - else: # process did not finish within 2 minutes - logging.error('timeout waiting for the timelapse movie process to finish') + try: + output = _timelapse_process.stdout.read() + + except IOError as e: + if e.errno == errno.EAGAIN: + output = '' - callback(None) + else: + raise + + frame_index = re.findall('frame=\s*(\d+)', output) + try: + frame_index = int(frame_index[-1]) + + except (IndexError, ValueError): + return + + _timelapse_process.progress = max(0.01, float(frame_index) / len(pictures)) + + logging.debug('timelapse progress: %s' % int(100 * _timelapse_process.progress)) else: # finished + _timelapse_process = None + logging.debug('reading timelapse movie file "%s" into memory' % tmp_filename) try: with open(tmp_filename, mode='r') as f: - data = f.read() + _timelapse_data = f.read() + + logging.debug('timelapse movie process has returned %d bytes' % len(_timelapse_data)) - logging.debug('timelapse movie process has returned %d bytes' % len(data)) - except Exception as e: logging.error('failed to read timelapse movie file "%s": %s' % (tmp_filename, e)) - return callback(None) finally: os.remove(tmp_filename) - callback(data) - poll_media_list_process() +def check_timelapse_movie(): + if _timelapse_process and _timelapse_process.poll() is None: + return {'progress': _timelapse_process.progress, 'data': None} + + else: + return {'progress': -1, 'data': _timelapse_data} + + 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) @@ -689,7 +732,7 @@ def set_prepared_cache(data): 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) + timeout = 3600 # the user has 1 hour to download the file after creation ioloop.IOLoop.instance().add_timeout(datetime.timedelta(seconds=timeout), clear) return key diff --git a/src/remote.py b/src/remote.py index 77cf5f2..120358f 100644 --- a/src/remote.py +++ b/src/remote.py @@ -15,12 +15,14 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import functools import json import logging from tornado.httpclient import AsyncHTTPClient, HTTPRequest import settings +import utils def _make_request(host, port, username, password, uri, method='GET', data=None, query=None, timeout=None): @@ -30,16 +32,42 @@ def _make_request(host, port, username, password, uri, method='GET', data=None, 'port': ':' + str(port) if port else '', 'uri': uri or ''} - if query: - url += '?' + '&'.join([(n + '=' + v) for (n, v) in query.iteritems()]) + query = dict(query or {}) + query['_username'] = username + query['_admin'] = 'true' # always use the admin account + if url.count('?'): + url += '&' + + else: + url += '?' + + url += '&'.join([(n + '=' + v) for (n, v) in query.iteritems()]) + url += '&_signature=' + utils.compute_signature(method, url, data, password) + if timeout is None: timeout = settings.REMOTE_REQUEST_TIMEOUT - request = HTTPRequest(url, method, body=data, auth_username=username, auth_password=password, - connect_timeout=timeout, request_timeout=timeout) + return HTTPRequest(url, method, body=data, connect_timeout=timeout, request_timeout=timeout) + + +def _callback_wrapper(callback): + @functools.wraps(callback) + def wrapper(response): + try: + decoded = json.loads(response.body) + if decoded['error'] == 'unauthorized': + response.error = 'Authentication Error' + + elif decoded['error']: + response.error = decoded['error'] + + except: + pass + + return callback(response) - return request + return wrapper def make_camera_url(local_config, camera=True): @@ -95,10 +123,10 @@ def list_cameras(local_config, callback): return callback(error=unicode(e)) - return callback(response['cameras']) + callback(response['cameras']) http_client = AsyncHTTPClient() - http_client.fetch(request, on_response) + http_client.fetch(request, _callback_wrapper(on_response)) def get_config(local_config, callback): @@ -140,7 +168,7 @@ def get_config(local_config, callback): callback(response) http_client = AsyncHTTPClient() - http_client.fetch(request, on_response) + http_client.fetch(request, _callback_wrapper(on_response)) def set_config(local_config, ui_config, callback): @@ -180,7 +208,7 @@ def set_config(local_config, ui_config, callback): callback() http_client = AsyncHTTPClient() - http_client.fetch(request, on_response) + http_client.fetch(request, _callback_wrapper(on_response)) def set_preview(local_config, controls, callback): @@ -211,10 +239,10 @@ def set_preview(local_config, controls, callback): callback() http_client = AsyncHTTPClient() - http_client.fetch(request, on_response) + http_client.fetch(request, _callback_wrapper(on_response)) -def get_current_picture(local_config, callback, width, height): +def get_current_picture(local_config, width, height, callback): 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')) @@ -257,10 +285,10 @@ def get_current_picture(local_config, callback, width, height): callback(motion_detected, response.body) http_client = AsyncHTTPClient() - http_client.fetch(request, on_response) + http_client.fetch(request, _callback_wrapper(on_response)) -def list_media(local_config, callback, media_type, prefix=None): +def list_media(local_config, media_type, prefix, callback): 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')) @@ -302,10 +330,10 @@ def list_media(local_config, callback, media_type, prefix=None): return callback(response) http_client = AsyncHTTPClient() - http_client.fetch(request, on_response) + http_client.fetch(request, _callback_wrapper(on_response)) -def get_media_content(local_config, callback, filename, media_type): +def get_media_content(local_config, filename, media_type, callback): 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')) @@ -339,10 +367,10 @@ def get_media_content(local_config, callback, filename, media_type): return callback(response.body) http_client = AsyncHTTPClient() - http_client.fetch(request, on_response) + http_client.fetch(request, _callback_wrapper(on_response)) -def get_zipped_content(local_config, media_type, callback, group): +def make_zipped_content(local_config, media_type, group, callback): 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')) @@ -350,7 +378,7 @@ def get_zipped_content(local_config, media_type, callback, group): 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' % { + logging.debug('preparing 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)}) @@ -363,9 +391,9 @@ def get_zipped_content(local_config, media_type, callback, 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): + def on_response(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' % { + logging.error('failed to prepare 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), @@ -375,7 +403,7 @@ def get_zipped_content(local_config, media_type, callback, group): 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), @@ -383,34 +411,49 @@ def get_zipped_content(local_config, media_type, callback, group): 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} + callback({'key': key}) + + http_client = AsyncHTTPClient() + http_client.fetch(request, _callback_wrapper(on_response)) - 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)}) +def get_zipped_content(local_config, media_type, key, callback): + 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')) - return callback(error=unicode(response.error)) + logging.debug('downloading zip file for remote camera %(id)s on %(url)s' % { + 'id': camera_id, + 'url': make_camera_url(local_config)}) + + request = _make_request(host, port, username, password, uri + '/%(media_type)s/%(id)s/zipped/nevermind/?key=%(key)s' % { + 'media_type': media_type, + 'id': camera_id, + 'key': key}) - callback(response.body) + def on_response(response): + if response.error: + logging.error('failed to download zip file for remote camera %(id)s on %(url)s: %(msg)s' % { + 'id': camera_id, + 'url': make_camera_url(local_config), + 'msg': unicode(response.error)}) + + return callback(error=unicode(response.error)) - http_client = AsyncHTTPClient() - http_client.fetch(request, on_download) + callback({ + 'data': response.body, + 'content_type': response.headers.get('Content-Type'), + 'content_disposition': response.headers.get('Content-Disposition') + }) http_client = AsyncHTTPClient() - http_client.fetch(request, on_prepare) + http_client.fetch(request, _callback_wrapper(on_response)) -def get_timelapse_movie(local_config, framerate, interval, callback, group): +def make_timelapse_movie(local_config, framerate, interval, group, callback): 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')) @@ -418,25 +461,24 @@ def get_timelapse_movie(local_config, framerate, interval, callback, group): 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 rate %(framerate)s/%(int)s on %(url)s' % { + logging.debug('making timelapse movie for group %(group)s of remote camera %(id)s with rate %(framerate)s/%(int)s on %(url)s' % { 'group': group, 'id': camera_id, 'framerate': framerate, 'int': interval, 'url': make_camera_url(local_config)}) - prepare_uri = uri + '/picture/%(id)s/timelapse/%(group)s/?interval=%(int)s&framerate=%(framerate)s' % { + uri += '/picture/%(id)s/timelapse/%(group)s/?interval=%(int)s&framerate=%(framerate)s' % { 'id': camera_id, 'int': interval, 'framerate': framerate, 'group': group} + + request = _make_request(host, port, username, password, uri) - # 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): + def on_response(response): if response.error: - logging.error('failed to download timelapse movie for group %(group)s of remote camera %(id)s with rate %(framerate)s/%(int)s on %(url)s: %(msg)s' % { + logging.error('failed to make timelapse movie for group %(group)s of remote camera %(id)s with rate %(framerate)s/%(int)s on %(url)s: %(msg)s' % { 'group': group, 'id': camera_id, 'url': make_camera_url(local_config), @@ -447,44 +489,96 @@ def get_timelapse_movie(local_config, framerate, interval, callback, group): return callback(error=unicode(response.error)) try: - key = json.loads(response.body)['key'] - + response = json.loads(response.body) + 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)) + + callback(response) - download_uri = uri + '/picture/%(id)s/timelapse/%(group)s/?key=%(key)s' % { - 'id': camera_id, - 'group': group, - 'key': key} + http_client = AsyncHTTPClient() + http_client.fetch(request, _callback_wrapper(on_response)) - 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 rate %(framerate)s/%(int)s on %(url)s: %(msg)s' % { - 'group': group, - 'id': camera_id, - 'url': make_camera_url(local_config), - 'int': interval, - 'framerate': framerate, - 'msg': unicode(response.error)}) +def check_timelapse_movie(local_config, callback): + 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')) - return callback(error=unicode(response.error)) + logging.debug('checking timelapse movie status for remote camera %(id)s on %(url)s' % { + 'id': camera_id, + 'url': make_camera_url(local_config)}) + + request = _make_request(host, port, username, password, uri + '/picture/%(id)s/timelapse/nevermind/?check=true' % {'id': camera_id}) + + def on_response(response): + if response.error: + logging.error('failed to check timelapse movie status for remote camera %(id)s on %(url)s: %(msg)s' % { + 'id': camera_id, + 'url': make_camera_url(local_config), + 'msg': unicode(response.error)}) + + return callback(error=unicode(response.error)) + + try: + response = json.loads(response.body) + + 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)) + + callback(response) + + http_client = AsyncHTTPClient() + http_client.fetch(request, _callback_wrapper(on_response)) + + +def get_timelapse_movie(local_config, key, callback): + 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 remote camera %(id)s on %(url)s' % { + 'id': camera_id, + 'url': make_camera_url(local_config)}) + + request = _make_request(host, port, username, password, uri + '/picture/%(id)s/timelapse/nevermind/?key=%(key)s' % { + 'id': camera_id, + 'key': key}) + + def on_response(response): + if response.error: + logging.error('failed to download timelapse movie for remote camera %(id)s on %(url)s: %(msg)s' % { + 'id': camera_id, + 'url': make_camera_url(local_config), + 'msg': unicode(response.error)}) - callback(response.body) + return callback(error=unicode(response.error)) - http_client = AsyncHTTPClient() - http_client.fetch(request, on_download) + callback({ + 'data': response.body, + 'content_type': response.headers.get('Content-Type'), + 'content_disposition': response.headers.get('Content-Disposition') + }) http_client = AsyncHTTPClient() - http_client.fetch(request, on_prepare) + http_client.fetch(request, _callback_wrapper(on_response)) -def get_media_preview(local_config, callback, filename, media_type, width, height): +def get_media_preview(local_config, filename, media_type, width, height, callback): 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')) @@ -522,13 +616,13 @@ def get_media_preview(local_config, callback, filename, media_type, width, heigh return callback(error=unicode(response.error)) - return callback(response.body) + callback(response.body) http_client = AsyncHTTPClient() - http_client.fetch(request, on_response) + http_client.fetch(request, _callback_wrapper(on_response)) -def del_media_content(local_config, callback, filename, media_type): +def del_media_content(local_config, filename, media_type, callback): 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')) @@ -558,13 +652,13 @@ def del_media_content(local_config, callback, filename, media_type): return callback(error=unicode(response.error)) - return callback() + callback() http_client = AsyncHTTPClient() - http_client.fetch(request, on_response) + http_client.fetch(request, _callback_wrapper(on_response)) -def del_media_group(local_config, callback, group, media_type): +def del_media_group(local_config, group, media_type, callback): 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')) @@ -577,7 +671,7 @@ def del_media_group(local_config, callback, group, media_type): 'id': camera_id, 'url': make_camera_url(local_config)}) - uri += '/%(media_type)s/%(id)s/delete/%(group)s' % { + uri += '/%(media_type)s/%(id)s/delete_all/%(group)s/' % { 'media_type': media_type, 'id': camera_id, 'group': group} @@ -594,7 +688,7 @@ def del_media_group(local_config, callback, group, media_type): return callback(error=unicode(response.error)) - return callback() + callback() http_client = AsyncHTTPClient() - http_client.fetch(request, on_response) + http_client.fetch(request, _callback_wrapper(on_response)) diff --git a/src/utils.py b/src/utils.py index c768105..502c4fc 100644 --- a/src/utils.py +++ b/src/utils.py @@ -292,7 +292,7 @@ def test_netcam_url(data, callback): def compute_signature(method, uri, body, key): parts = list(urlparse.urlsplit(uri)) - query = [q for q in urlparse.parse_qsl(parts[3]) if (q[0] != 'signature')] + query = [q for q in urlparse.parse_qsl(parts[3], keep_blank_values=True) if (q[0] != '_signature')] query.sort(key=lambda q: q[0]) query = urllib.urlencode(query) parts[0] = parts[1] = '' diff --git a/static/css/main.css b/static/css/main.css index a50f1ec..f8b4d9a 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -430,33 +430,8 @@ input[type=text].working-schedule.number { width: 50px; } -span.disk-usage-text { - vertical-align: middle; -} - -div.disk-usage-bar-container { - position: relative; +#diskUsageProgressBar { width: 90%; - height: 1em; - border: 1px solid #555; - vertical-align: middle; - margin: 0px 0.2em; - text-align: center; - line-height: 1em; -} - -div.disk-usage-bar-fill { - position: absolute; - left: 0px; - top: 0px; - bottom: 0px; - width: 0%; - background-color: #555; -} - -span.disk-usage-percent { - font-size: 0.8em; - position: relative; } div.hidden, @@ -727,6 +702,16 @@ table.timelapse-dialog select { width: 10em; } +td.timelapse-warning { + font-size: 80%; + display: none; + color: red; + max-width: 20em; + text-align: center; + white-space: normal; + padding-bottom: 1em; +} + div.media-dialog-delete-all-button { margin-top: 0.1em; margin-bottom: 0.4em; diff --git a/static/css/ui.css b/static/css/ui.css index e3017d7..8663315 100644 --- a/static/css/ui.css +++ b/static/css/ui.css @@ -287,6 +287,35 @@ div.slider-cursor-label { } + /* progress bar */ + +div.progress-bar-container { + position: relative; + height: 1em; + border: 1px solid #555; + vertical-align: middle; + margin: 0px 0.2em; + text-align: center; + line-height: 1em; +} + +div.progress-bar-fill { + position: absolute; + left: 0px; + top: 0px; + bottom: 0px; + width: 0%; + background-color: #555; + transition: width 0.1s ease; +} + +span.progress-bar-text { + vertical-align: middle; + font-size: 0.8em; + position: relative; +} + + /* modal dialogs */ div.modal-glass { diff --git a/static/js/main.js b/static/js/main.js index 0f18d34..5cc0a1d 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -160,9 +160,9 @@ function addAuthParams(method, url, body) { url += '&';; } - url += 'username=' + window.username; + url += '_username=' + window.username; var signature = computeSignature(method, url, body); - url += '&signature=' + signature; + url += '&_signature=' + signature; return url; } @@ -497,6 +497,9 @@ function initUI() { makeTimeValidator($('#sundayFromEntry')); makeTimeValidator($('#sundayToEntry')); + /* progress bars */ + makeProgressBar($('#diskUsageProgressBar')); + /* ui elements that enable/disable other ui elements */ $('#motionEyeSwitch').change(updateConfigUi); $('#showAdvancedSwitch').change(updateConfigUi); @@ -1134,9 +1137,11 @@ function dict2CameraUi(dict) { if (dict['disk_total'] != 0) { percent = parseInt(dict['disk_used'] * 100 / dict['disk_total']); } - $('#diskUsageBarFill').css('width', percent + '%'); - $('#diskUsageText').html( - (dict['disk_used'] / 1073741824).toFixed(1) + '/' + (dict['disk_total'] / 1073741824).toFixed(1) + ' GB (' + percent + '%)'); + + $('#diskUsageProgressBar').each(function () { + this.setProgress(percent); + this.setText((dict['disk_used'] / 1073741824).toFixed(1) + '/' + (dict['disk_total'] / 1073741824).toFixed(1) + ' GB (' + percent + '%)'); + }); /* text overlay */ $('#textOverlaySwitch')[0].checked = dict['text_overlay']; @@ -1883,8 +1888,6 @@ function runLoginDialog(retry) { if (retry) { retry(); } - - //return false; }}, {caption: 'Login', isDefault: true, click: function () { window.username = usernameEntry.val(); @@ -1900,8 +1903,6 @@ function runLoginDialog(retry) { if (retry) { retry(); } - - //return false; }} ], }; @@ -2249,6 +2250,7 @@ function runAddCameraDialog() { function runTimelapseDialog(cameraId, groupKey, group) { var content = $('' + + '' + '' + '' + '' + @@ -2279,6 +2281,12 @@ function runTimelapseDialog(cameraId, groupKey, group) { var intervalSelect = content.find('#intervalSelect'); var framerateSlider = content.find('#framerateSlider'); + var timelapseWarning = content.find('td.timelapse-warning'); + + if (group.length > 1440) { /* one day worth of pictures, taken 1 minute apart */ + timelapseWarning.html('Given the large number of pictures, creating your timelapse might take a while!'); + timelapseWarning.css('display', 'table-cell'); + } makeSlider(framerateSlider, 1, 100, 0, [ {value: 1, label: '1'}, @@ -2298,14 +2306,68 @@ function runTimelapseDialog(cameraId, groupKey, group) { buttons: 'okcancel', content: content, onOk: function () { - showModalDialog('', null, null, true); - ajax('GET', '/picture/' + cameraId + '/timelapse/' + groupKey + '/', - {interval: intervalSelect.val(), framerate: framerateSlider.val()}, function (data) { - - hideModalDialog(); /* progress */ - hideModalDialog(); /* timelapse dialog */ - downloadFile('/picture/' + cameraId + '/timelapse/' + groupKey + '/?key=' + data.key); + var progressBar = $('
'); + makeProgressBar(progressBar); + + runModalDialog({ + title: 'Creating Timelapse Movie...', + content: progressBar, + stack: true, + noKeys: true }); + + var url = '/picture/' + cameraId + '/timelapse/' + groupKey + '/'; + var data = {interval: intervalSelect.val(), framerate: framerateSlider.val()}; + var first = true; + + function checkTimelapse() { + var actualUrl = url; + if (!first) { + actualUrl += '?check=true'; + } + + ajax('GET', actualUrl, data, function (data) { + if (data == null || data.error) { + hideModalDialog(); /* progress */ + hideModalDialog(); /* timelapse dialog */ + showErrorMessage(data && data.error); + return; + } + + if (data.progress != -1 && first) { + hideModalDialog(); /* progress */ + hideModalDialog(); /* timelapse dialog */ + showErrorMessage('A timelapse movie is already being created.'); + return; + } + + if (data.progress == -1 && !first && !data.key) { + hideModalDialog(); /* progress */ + hideModalDialog(); /* timelapse dialog */ + showErrorMessage('The timelapse movie could not be created.'); + return; + } + + if (data.progress == -1) { + data.progress = 0; + } + + if (data.key) { + hideModalDialog(); /* progress */ + hideModalDialog(); /* timelapse dialog */ + downloadFile('/picture/' + cameraId + '/timelapse/' + groupKey + '/?key=' + data.key); + } + else { + progressBar[0].setProgress(data.progress * 100); + progressBar[0].setText(parseInt(data.progress * 100) + '%'); + setTimeout(checkTimelapse, 1000); + } + + first = false; + }); + } + + checkTimelapse(); return false; }, @@ -2359,7 +2421,7 @@ function runMediaDialog(cameraId, mediaType) { function addEntries() { /* add the entries to the media list */ - entries.forEach(function (entry) { + entries.forEach(function (entry, i) { var entryDiv = entry.div; var detailsDiv = null; @@ -2393,6 +2455,18 @@ function runMediaDialog(cameraId, mediaType) { deleteButton.click(function () { doDeleteFile('/' + mediaType + '/' + cameraId + '/delete' + entry.path, function () { entryDiv.remove(); + entries.splice(i, 1); /* remove entry from group */ + + /* update text on group button */ + groupsDiv.find('div.media-dialog-group-button').each(function () { + var $this = $(this); + if (this.key == groupKey) { + var text = this.innerHTML; + text = text.substring(0, text.lastIndexOf(' ')); + text += ' (' + entries.length + ')'; + this.innerHTML = text; + } + }); }); return false; @@ -2469,7 +2543,6 @@ function runMediaDialog(cameraId, mediaType) { } if (mediaType == 'picture') { - var zippedButton = $('
Zipped
'); buttonsDiv.append(zippedButton); diff --git a/static/js/ui.js b/static/js/ui.js index f53df84..49edb69 100644 --- a/static/js/ui.js +++ b/static/js/ui.js @@ -280,13 +280,13 @@ function makeSlider($input, minVal, maxVal, snapMode, ticks, ticksNumber, decima this.update = input2slider; }); - slider.setMinVal = function (mv) { + slider[0].setMinVal = function (mv) { minVal = mv; makeTicks(); }; - slider.setMaxVal = function (mv) { + slider[0].setMaxVal = function (mv) { maxVal = mv; makeTicks(); @@ -559,6 +559,26 @@ function makeUrlValidator($input) { }); } +function makeProgressBar($div) { + $div.addClass('progress-bar-container'); + var fillDiv = $('
'); + var textSpan = $(''); + + $div.append(fillDiv); + $div.append(textSpan); + + $div[0].setProgress = function (progress) { + $div.progress = progress; + fillDiv.width(progress + '%'); + }; + + $div[0].setText = function (text) { + textSpan.html(text); + }; + + return $div; +} + /* modal dialog */ @@ -758,6 +778,7 @@ function runModalDialog(options) { * * onClose: Function * * onShow: Function * * stack: Boolean + * * noKeys: Boolean */ var content = $('
'); @@ -828,7 +849,7 @@ function runModalDialog(options) { buttonsDiv.css('margin-top', '5px'); } - var handleKeyUp = function (e) { + var handleKeyUp = !options.noKeys && function (e) { if (!content.is(':visible')) { return; } diff --git a/templates/main.html b/templates/main.html index 3e3216d..136a7eb 100644 --- a/templates/main.html +++ b/templates/main.html @@ -232,10 +232,7 @@ -- 2.39.5
Group' + groupKey + '
Disk Usage -
-
- -
+
?