]> www.vanbest.org Git - motioneye-debian/commitdiff
added support for uploading media files to Google Drive
authorCalin Crisan <ccrisan@gmail.com>
Sun, 22 Nov 2015 18:22:27 +0000 (20:22 +0200)
committerCalin Crisan <ccrisan@gmail.com>
Sun, 22 Nov 2015 18:42:58 +0000 (20:42 +0200)
13 files changed:
extra/motioneye.init-debian
motioneye/config.py
motioneye/handlers.py
motioneye/meyectl.py
motioneye/prefs.py
motioneye/remote.py
motioneye/server.py
motioneye/static/css/main.css
motioneye/static/js/main.js
motioneye/tasker.py [deleted file]
motioneye/tasks.py [new file with mode: 0644]
motioneye/templates/main.html
motioneye/uploadservices.py [new file with mode: 0644]

index 8dd7aeecf989c6159be1bd37c6965dc9539a3412..660e91f63525a914e3bac9fedeeb48e1afb2280f 100755 (executable)
@@ -13,7 +13,7 @@
 ### END INIT INFO
 
 NAME="motioneye"
-PATH_BIN="/bin:/usr/bin:/sbin:/usr/sbin:/usr/local/bin:/usr/local/sbin"
+PATH="/bin:/usr/bin:/sbin:/usr/sbin:/usr/local/bin:/usr/local/sbin"
 DAEMON="/usr/local/bin/meyectl"
 PIDFILE="/var/run/$NAME.pid"
 DESC="motionEye server"
index 104baf91c71c341605b31ec21168454a56ed3708..5646ed599c8434ac25a2dc258d47b2f9688b309c 100644 (file)
@@ -31,6 +31,7 @@ import diskctl
 import powerctl
 import settings
 import update
+import uploadservices
 import utils
 import v4l2ctl
 
@@ -280,7 +281,8 @@ def get_camera(camera_id, as_lines=False):
         
     camera_config = _conf_to_dict(lines,
             no_convert=['@name', '@network_share_name', '@network_server',
-                        '@network_username', '@network_password', '@storage_device'])
+                        '@network_username', '@network_password', '@storage_device',
+                        '@upload_server', '@upload_username', '@upload_password', '@upload_authorization_key'])
     
     if utils.local_motion_camera(camera_config):
         # determine the enabled status
@@ -323,12 +325,16 @@ def get_camera(camera_id, as_lines=False):
                 camera_config['netcam_keepalive'] = camera_config.pop('netcam_http') in ['1.1', 'keepalive']
 
         _get_additional_config(camera_config, camera_id=camera_id)
+        
+        _set_default_motion_camera(camera_id, camera_config)
     
     elif utils.remote_camera(camera_config):
         pass
     
     elif utils.simple_mjpeg_camera(camera_config):
         _get_additional_config(camera_config, camera_id=camera_id)
+        
+        _set_default_simple_mjpeg_camera(camera_id, camera_config)
     
     else: # incomplete configuration
         logging.warn('camera config file at %s is incomplete, ignoring' % camera_config_path)
@@ -631,6 +637,15 @@ def motion_camera_ui_to_dict(ui, old_config=None):
         '@network_share_name': ui['network_share_name'],
         '@network_username': ui['network_username'],
         '@network_password': ui['network_password'],
+        '@upload_enabled': ui['upload_enabled'],
+        '@upload_service': ui['upload_service'],
+        '@upload_server': ui['upload_server'],
+        '@upload_port': ui['upload_port'],
+        '@upload_method': ui['upload_method'],
+        '@upload_location': ui['upload_location'],
+        '@upload_username': ui['upload_username'],
+        '@upload_password': ui['upload_password'],
+        '@upload_authorization_key': ui['upload_authorization_key'],
         
         # text overlay
         'text_left': '',
@@ -767,6 +782,12 @@ def motion_camera_ui_to_dict(ui, old_config=None):
 
     else:
         data['target_dir'] = ui['root_directory']
+        
+    if ui['upload_enabled'] and '@id' in old_config:
+        upload_settings = {k[7:]: ui[k] for k in ui.iterkeys() if k.startswith('upload_')}
+        service = uploadservices.get(old_config['@id'], ui['upload_service'])
+        service.load(upload_settings)
+        uploadservices.save()
 
     if ui['text_overlay']:
         left_text = ui['left_text']
@@ -932,6 +953,15 @@ def motion_camera_dict_to_ui(data):
         'disk_used': 0,
         'disk_total': 0,
         'available_disks': diskctl.list_mounted_disks(),
+        'upload_enabled': data['@upload_enabled'],
+        'upload_service': data['@upload_service'],
+        'upload_server': data['@upload_server'],
+        'upload_port': data['@upload_port'],
+        'upload_method': data['@upload_method'],
+        'upload_location': data['@upload_location'],
+        'upload_username': data['@upload_username'],
+        'upload_password': data['@upload_password'],
+        'upload_authorization_key': data['@upload_authorization_key'],
 
         # text overlay
         'text_overlay': False,
@@ -1619,7 +1649,16 @@ def _set_default_motion_camera(camera_id, data):
     data.setdefault('@network_username', '')
     data.setdefault('@network_password', '')
     data.setdefault('target_dir', settings.MEDIA_PATH)
-    
+    data.setdefault('@upload_enabled', False)
+    data.setdefault('@upload_service', 'ftp')
+    data.setdefault('@upload_server', '')
+    data.setdefault('@upload_port', '')
+    data.setdefault('@upload_method', 'POST')
+    data.setdefault('@upload_location', '')
+    data.setdefault('@upload_username', '')
+    data.setdefault('@upload_password', '')
+    data.setdefault('@upload_authorization_key', '')
+
     data.setdefault('stream_localhost', False)
     data.setdefault('stream_port', int('808' + str(camera_id)))
     data.setdefault('stream_maxrate', 5)
index 3dc1f3363955c837334cdfd4a73ebe2adf900886..3042f7e3b709b2661db682140a4e06e846ad1dc5 100644 (file)
@@ -34,9 +34,10 @@ import prefs
 import remote
 import settings
 import smbctl
-import tasker
+import tasks
 import template
 import update
+import uploadservices
 import utils
 import v4l2ctl
 
@@ -212,11 +213,8 @@ class ConfigHandler(BaseHandler):
         elif op == 'backup':
             self.backup()
             
-        elif op == 'test':
-            self.test()
-            
         elif op == 'authorize':
-            self.authorize()
+            self.authorize(camera_id)
 
         else:
             raise HTTPError(400, 'unknown operation')
@@ -241,6 +239,9 @@ class ConfigHandler(BaseHandler):
         elif op == 'restore':
             self.restore()
         
+        elif op == 'test':
+            self.test(camera_id)
+            
         else:
             raise HTTPError(400, 'unknown operation')
     
@@ -733,12 +734,59 @@ class ConfigHandler(BaseHandler):
             self.finish_json({'ok': False})
     
     @BaseHandler.auth(admin=True)
-    def test(self):
-        pass
+    def test(self, camera_id):
+        what = self.get_argument('what')
+        data = self.get_all_arguments()
+        camera_config = config.get_camera(camera_id)
+        if what == 'upload_service':
+            service_name = data.get('service')
+            if not service_name:
+                raise HTTPError(400, 'service_name required')
+
+            if utils.local_motion_camera(camera_config): 
+                service = uploadservices.get(camera_id, service_name)
+                service.load(data)
+                if not service:
+                    raise HTTPError(400, 'unknown upload service %s' % service_name)
+                
+                logging.debug('testing access to %s' % service)
+                result = service.test_access()
+                if result is True:
+                    logging.debug('accessing %s succeeded' % service)
+                    self.finish_json()
+    
+                else:
+                    logging.warn('accessing %s failed' % service)
+                    self.finish_json({'error': result})
+            
+            elif utils.remote_camera(camera_config):
+                def on_response(result=None, error=None):
+                    if result is True:
+                        self.finish_json()
+                        
+                    else:
+                        result = result or error
+                        self.finish_json({'error': result})
+
+                remote.test(camera_config, data, on_response)
+
+        else:
+            raise HTTPError(400, 'unknown test %s' % what)
 
     @BaseHandler.auth(admin=True)
-    def authorize(self):
-        pass
+    def authorize(self, camera_id):
+        service_name = self.get_argument('service')
+        if not service_name:
+            raise HTTPError(400, 'service_name required')
+
+        service = uploadservices.get(camera_id, service_name)
+        if not service:
+            raise HTTPError(400, 'unknown upload service %s' % service_name)
+
+        url = service.get_authorize_url()
+
+        logging.debug('redirected to authorization url %s' % url)
+        self.redirect(url)
 
 
 class PictureHandler(BaseHandler):
@@ -1436,15 +1484,26 @@ class RelayEventHandler(BaseHandler):
             motionctl.set_motion_detected(camera_id, False)
             
         elif event == 'movie_end':
-            full_path = self.get_argument('filename')
+            filename = self.get_argument('filename')
             
             # generate preview (thumbnail)
-            tasker.add_task(5, mediafiles.make_movie_preview, tag='make_movie_preview(%s)' % full_path, async=True,
-                    camera_config=camera_config, full_path=full_path)
-
-            # upload TODO
-#             tasker.add_task(5, upload.upload_media_file, tag='upload_media_file(%s)' % full_path,
-#                     camera_config=camera_config, full_path=full_path)
+            tasks.add(5, mediafiles.make_movie_preview, tag='make_movie_preview(%s)' % filename, async=True,
+                    camera_config=camera_config, full_path=filename)
+
+            # upload to external service
+            if camera_config['@upload_enabled']:
+                service_name = camera_config['@upload_service']
+                tasks.add(5, uploadservices.upload_media_file, tag='upload_media_file(%s)' % filename,
+                        camera_id=camera_id, service_name=service_name, filename=filename)
+
+        elif event == 'picture_save':
+            filename = self.get_argument('filename')
+            
+            # upload to external service
+            if camera_config['@upload_enabled']:
+                service_name = camera_config['@upload_service']
+                tasks.add(5, uploadservices.upload_media_file, tag='upload_media_file(%s)' % filename,
+                        camera_id=camera_id, service_name=service_name, filename=filename)
 
         else:
             logging.warn('unknown event %s' % event)
index 5e5ce6f8845dc7b8b52e587dad6ce52d485abfcc..ed0f2980f9d60a911d791bdca352ea3feae677b5 100755 (executable)
@@ -173,6 +173,7 @@ def configure_logging(cmd, log_to_file=False):
         sys.exit(-1)
 
     logging.getLogger('tornado').setLevel(logging.WARN)
+    logging.getLogger('oauth2client').setLevel(logging.WARN)
 
 
 def configure_tornado():
index 793277ce9c33dbaa2a00592bbda33802ff55df44..f2e00fb5378994888850d6608028bccc44b354cd 100644 (file)
@@ -23,6 +23,7 @@ import settings
 
 
 _PREFS_FILE_NAME = 'prefs.json'
+
 _prefs = None
 
 
index de4daf4c1411b05109006cec8e094ae33b846318..d1be83aa6eb592fa591998935fd4a41b6b1b6d10 100644 (file)
@@ -28,7 +28,7 @@ import utils
 _DOUBLE_SLASH_REGEX = re.compile('//+')
 
 
-def _make_request(scheme, host, port, username, password, path, method='GET', data=None, query=None, timeout=None):
+def _make_request(scheme, host, port, username, password, path, method='GET', data=None, query=None, timeout=None, content_type=None):
     path = _DOUBLE_SLASH_REGEX.sub('/', path)
     url = '%(scheme)s://%(host)s%(port)s%(path)s' % {
             'scheme': scheme,
@@ -51,8 +51,12 @@ def _make_request(scheme, host, port, username, password, path, method='GET', da
 
     if timeout is None:
         timeout = settings.REMOTE_REQUEST_TIMEOUT
-        
-    return HTTPRequest(url, method, body=data, connect_timeout=timeout, request_timeout=timeout)
+    
+    headers = {}
+    if content_type:
+        headers['Content-Type'] = content_type
+
+    return HTTPRequest(url, method, body=data, connect_timeout=timeout, request_timeout=timeout, headers=headers)
 
 
 def _callback_wrapper(callback):
@@ -117,7 +121,8 @@ def list(local_config, callback):
     logging.debug('listing remote cameras on %(url)s' % {
             'url': pretty_camera_url(local_config, camera=False)})
     
-    request = _make_request(scheme, host, port, username, password, path + '/config/list/')
+    request = _make_request(scheme, host, port, username, password,
+            path + '/config/list/')
     
     def on_response(response):
         def make_camera_response(c):
@@ -162,7 +167,8 @@ def get_config(local_config, callback):
             'id': camera_id,
             'url': pretty_camera_url(local_config)})
     
-    request = _make_request(scheme, host, port, username, password, path + '/config/%(id)s/get/' % {'id': camera_id})
+    request = _make_request(scheme, host, port, username, password,
+            path + '/config/%(id)s/get/' % {'id': camera_id})
     
     def on_response(response):
         if response.error:
@@ -207,7 +213,9 @@ def set_config(local_config, ui_config, callback):
     
     ui_config = json.dumps(ui_config)
     
-    request = _make_request(scheme, host, port, username, password, path + '/config/%(id)s/set/' % {'id': camera_id}, method='POST', data=ui_config)
+    request = _make_request(scheme, host, port, username, password,
+            path + '/config/%(id)s/set/' % {'id': camera_id},
+            method='POST', data=ui_config, content_type='application/json')
     
     def on_response(response):
         if response.error:
@@ -233,7 +241,9 @@ def set_preview(local_config, controls, callback):
     
     data = json.dumps(controls)
     
-    request = _make_request(scheme, host, port, username, password, path + '/config/%(id)s/set_preview/' % {'id': camera_id}, method='POST', data=data)
+    request = _make_request(scheme, host, port, username, password,
+            path + '/config/%(id)s/set_preview/' % {'id': camera_id},
+            method='POST', data=data, content_type='application/json')
 
     def on_response(response):
         if response.error:
@@ -250,12 +260,42 @@ def set_preview(local_config, controls, callback):
     http_client.fetch(request, _callback_wrapper(on_response))
 
 
-def get_current_picture(local_config, width, height, callback):
+def test(local_config, data, callback):
     scheme, host, port, username, password, path, camera_id = _remote_params(local_config)
-    
-    logging.debug('getting current picture for remote camera %(id)s on %(url)s' % {
+    what = data['what']
+    logging.debug('testing %(what)s on remote camera %(id)s, on %(url)s' % {
+            'what': what,
             'id': camera_id,
             'url': pretty_camera_url(local_config)})
+
+    data = json.dumps(data)
+
+    request = _make_request(scheme, host, port, username, password,
+            path + '/config/%(id)s/test/' % {'id': camera_id},
+            method='POST', data=data, content_type='application/json')
+
+    def on_response(response):
+        if response.error:
+            logging.error('failed to test %(what)s on remote camera %(id)s, on %(url)s: %(msg)s' % {
+                    'what': what,
+                    'id': camera_id,
+                    'url': pretty_camera_url(local_config),
+                    'msg': utils.pretty_http_error(response)})
+
+            return callback(error=utils.pretty_http_error(response))
+        
+        callback()
+
+    http_client = AsyncHTTPClient()
+    http_client.fetch(request, _callback_wrapper(on_response))
+
+
+def get_current_picture(local_config, width, height, callback):
+    scheme, host, port, username, password, path, camera_id = _remote_params(local_config)
+    
+#     logging.debug('getting current picture for remote camera %(id)s on %(url)s' % {
+#             'id': camera_id,
+#             'url': pretty_camera_url(local_config)})
     
     query = {}
     
@@ -265,7 +305,9 @@ def get_current_picture(local_config, width, height, callback):
     if height:
         query['height'] = str(height)
     
-    request = _make_request(scheme, host, port, username, password, path + '/picture/%(id)s/current/' % {'id': camera_id}, query=query)
+    request = _make_request(scheme, host, port, username, password,
+            path + '/picture/%(id)s/current/' % {'id': camera_id},
+            query=query)
     
     def on_response(response):
         motion_detected = False
@@ -303,8 +345,10 @@ def list_media(local_config, media_type, prefix, callback):
         query['prefix'] = prefix
     
     # 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 + '/%(media_type)s/%(id)s/list/' % {
-            'id': camera_id, 'media_type': media_type}, query=query, timeout=10 * settings.REMOTE_REQUEST_TIMEOUT)
+    request = _make_request(scheme, host, port, username, password,
+            path + '/%(media_type)s/%(id)s/list/' % {
+            'id': camera_id, 'media_type': media_type}, query=query,
+            timeout=10 * settings.REMOTE_REQUEST_TIMEOUT)
     
     def on_response(response):
         if response.error:
@@ -345,7 +389,8 @@ def get_media_content(local_config, filename, media_type, callback):
             '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)
+    request = _make_request(scheme, host, port, username, password,
+            path, timeout=10 * settings.REMOTE_REQUEST_TIMEOUT)
     
     def on_response(response):
         if response.error:
@@ -377,7 +422,8 @@ def make_zipped_content(local_config, media_type, group, callback):
             'group': group}
  
     # timeout here is 100 times larger than usual - we expect a big delay
-    request = _make_request(scheme, host, port, username, password, prepare_path, timeout=100 * settings.REMOTE_REQUEST_TIMEOUT)
+    request = _make_request(scheme, host, port, username, password,
+            prepare_path, timeout=100 * settings.REMOTE_REQUEST_TIMEOUT)
 
     def on_response(response):
         if response.error:
@@ -412,11 +458,12 @@ def get_zipped_content(local_config, media_type, key, group, callback):
             'id': camera_id,
             'url': pretty_camera_url(local_config)})
     
-    request = _make_request(scheme, host, port, username, password, path + '/%(media_type)s/%(id)s/zipped/%(group)s/?key=%(key)s' % {
-            'media_type': media_type,
-            'group': group,
-            'id': camera_id,
-            'key': key},
+    request = _make_request(scheme, host, port, username, password,
+            path + '/%(media_type)s/%(id)s/zipped/%(group)s/?key=%(key)s' % {
+                    'media_type': media_type,
+                    'group': group,
+                    'id': camera_id,
+                    'key': key},
             timeout=10 * settings.REMOTE_REQUEST_TIMEOUT)
 
     def on_response(response):
@@ -454,7 +501,8 @@ def make_timelapse_movie(local_config, framerate, interval, group, callback):
             'framerate': framerate,
             'group': group}
     
-    request = _make_request(scheme, host, port, username, password, path, timeout=100 * settings.REMOTE_REQUEST_TIMEOUT)
+    request = _make_request(scheme, host, port, username, password,
+            path, timeout=100 * settings.REMOTE_REQUEST_TIMEOUT)
 
     def on_response(response):
         if response.error:
@@ -491,9 +539,10 @@ def check_timelapse_movie(local_config, group, callback):
             'id': camera_id,
             'url': pretty_camera_url(local_config)})
     
-    request = _make_request(scheme, host, port, username, password, path + '/picture/%(id)s/timelapse/%(group)s/?check=true' % {
-            'id': camera_id,
-            'group': group})
+    request = _make_request(scheme, host, port, username, password,
+            path + '/picture/%(id)s/timelapse/%(group)s/?check=true' % {
+                    'id': camera_id,
+                    'group': group})
     
     def on_response(response):
         if response.error:
@@ -527,10 +576,11 @@ def get_timelapse_movie(local_config, key, group, callback):
             'id': camera_id,
             'url': pretty_camera_url(local_config)})
     
-    request = _make_request(scheme, host, port, username, password, path + '/picture/%(id)s/timelapse/%(group)s/?key=%(key)s' % {
-            'id': camera_id,
-            'group': group,
-            'key': key},
+    request = _make_request(scheme, host, port, username, password,
+            path + '/picture/%(id)s/timelapse/%(group)s/?key=%(key)s' % {
+                'id': camera_id,
+                'group': group,
+                'key': key},
             timeout=10 * settings.REMOTE_REQUEST_TIMEOUT)
 
     def on_response(response):
@@ -573,7 +623,8 @@ def get_media_preview(local_config, filename, media_type, width, height, callbac
     if height:
         query['height'] = str(height)
     
-    request = _make_request(scheme, host, port, username, password, path, query=query)
+    request = _make_request(scheme, host, port, username, password,
+            path, query=query)
     
     def on_response(response):
         if response.error:
@@ -604,7 +655,9 @@ def del_media_content(local_config, filename, media_type, callback):
             'id': camera_id,
             'filename': filename}
 
-    request = _make_request(scheme, host, port, username, password, path, method='POST', data='{}', timeout=settings.REMOTE_REQUEST_TIMEOUT)
+    request = _make_request(scheme, host, port, username, password,
+            path, method='POST', data='{}',
+            timeout=settings.REMOTE_REQUEST_TIMEOUT, content_type='application/json')
 
     def on_response(response):
         if response.error:
@@ -635,7 +688,9 @@ def del_media_group(local_config, group, media_type, callback):
             'id': camera_id,
             'group': group}
 
-    request = _make_request(scheme, host, port, username, password, path, method='POST', data='{}', timeout=settings.REMOTE_REQUEST_TIMEOUT)
+    request = _make_request(scheme, host, port, username, password, path,
+            method='POST', data='{}',
+            timeout=settings.REMOTE_REQUEST_TIMEOUT, content_type='application/json')
 
     def on_response(response):
         if response.error:
index 8a8b8d2f651ec7c410d62901e8f23712ebead8db..f379ba54b7ee124e38f6ae56ff978a69c0189708 100644 (file)
@@ -168,8 +168,8 @@ def _log_request(handler):
 handler_mapping = [
     (r'^/$', handlers.MainHandler),
     (r'^/config/main/(?P<op>set|get)/?$', handlers.ConfigHandler),
-    (r'^/config/(?P<camera_id>\d+)/(?P<op>get|set|rem|set_preview)/?$', handlers.ConfigHandler),
-    (r'^/config/(?P<op>add|list|backup|restore|test|authorize)/?$', handlers.ConfigHandler),
+    (r'^/config/(?P<camera_id>\d+)/(?P<op>get|set|rem|set_preview|test|authorize)/?$', handlers.ConfigHandler),
+    (r'^/config/(?P<op>add|list|backup|restore)/?$', 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|delete)/(?P<filename>.+?)/?$', handlers.PictureHandler),
     (r'^/picture/(?P<camera_id>\d+)/(?P<op>zipped|timelapse|delete_all)/(?P<group>.*?)/?$', handlers.PictureHandler),
@@ -330,7 +330,8 @@ def run():
     import motionctl
     import motioneye
     import smbctl
-    import tasker
+    import tasks
+    import uploadservices
     import wsswitch
 
     configure_signals()
@@ -353,8 +354,11 @@ def run():
     wsswitch.start()
     logging.info('wsswitch started')
 
-    tasker.start()
-    logging.info('tasker started')
+    tasks.start()
+    logging.info('tasks started')
+
+    uploadservices.load()
+    logging.info('upload services loaded')
 
     if settings.MJPG_CLIENT_TIMEOUT:
         mjpgclient.start()
@@ -377,6 +381,9 @@ def run():
 
     logging.info('server stopped')
     
+    tasks.stop()
+    logging.info('tasks stopped')
+
     if cleanup.running():
         cleanup.stop()
         logging.info('cleanup stopped')
index 2c824059b8b479196029e82af000d88b4cc0114b..c0dbc0d763ab9a8158e97c6fb860dea28c61ba8b 100644 (file)
@@ -375,7 +375,8 @@ div.normal-button {
 
 div.update-button,
 div.backup-button,
-div.restore-button {
+div.restore-button,
+div.test-button {
     background: #317CAD;
 }
 
index 2a43652b68799c402854b47c09ba2f74382248d5..5174eea328e025f2972ebeaaebbaa0aac2e4eaee 100644 (file)
@@ -495,6 +495,18 @@ String.prototype.format = function () {
     return text;
 };
 
+
+    /* various */
+
+function authorizeUpload() {
+    var service = $('#uploadServiceSelect').val();
+    var cameraId = $('#cameraSelect').val();
+    var url = basePath + 'config/' + cameraId + '/authorize/?service=' + service;
+    url = addAuthParams('GET', url);
+
+    window.open(url, '_blank');
+}
+
     
     /* UI initialization */
 
@@ -1352,6 +1364,15 @@ function cameraUi2Dict() {
         'network_username': $('#networkUsernameEntry').val(),
         'network_password': $('#networkPasswordEntry').val(),
         'root_directory': $('#rootDirectoryEntry').val(),
+        'upload_enabled': $('#uploadEnabledSwitch')[0].checked,
+        'upload_service': $('#uploadServiceSelect').val(),
+        'upload_server': $('#uploadServerEntry').val(),
+        'upload_port': $('#uploadPortEntry').val(),
+        'upload_method': $('#uploadMethodSelect').val(),
+        'upload_location': $('#uploadLocationEntry').val(),
+        'upload_username': $('#uploadUsernameEntry').val(),
+        'upload_password': $('#uploadPasswordEntry').val(),
+        'upload_authorization_key': $('#uploadAuthorizationKeyEntry').val(),
         
         /* text overlay */
         'text_overlay': $('#textOverlaySwitch')[0].checked,
@@ -1623,12 +1644,22 @@ function dict2CameraUi(dict) {
     if (dict['disk_total'] != 0) {
         percent = parseInt(dict['disk_used'] * 100 / dict['disk_total']);
     }
-    
+
     $('#diskUsageProgressBar').each(function () {
         this.setProgress(percent);
         this.setText((dict['disk_used'] / 1073741824).toFixed(1)  + '/' + (dict['disk_total'] / 1073741824).toFixed(1) + ' GB (' + percent + '%)');
     }); markHideIfNull('disk_used', 'diskUsageProgressBar');
     
+    $('#uploadEnabledSwitch')[0].checked = dict['upload_enabled']; markHideIfNull('upload_enabled', 'uploadEnabledSwitch');
+    $('#uploadServiceSelect').val(dict['upload_service']); markHideIfNull('upload_service', 'uploadServiceSelect');
+    $('#uploadServerEntry').val(dict['upload_server']); markHideIfNull('upload_server', 'uploadServerEntry');
+    $('#uploadPortEntry').val(dict['upload_port']); markHideIfNull('upload_port', 'uploadPortEntry');
+    $('#uploadMethodSelect').val(dict['upload_method']); markHideIfNull('upload_method', 'uploadMethodSelect');
+    $('#uploadLocationEntry').val(dict['upload_location']); markHideIfNull('upload_location', 'uploadLocationEntry');
+    $('#uploadUsernameEntry').val(dict['upload_username']); markHideIfNull('upload_username', 'uploadUsernameEntry');
+    $('#uploadPasswordEntry').val(dict['upload_password']); markHideIfNull('upload_password', 'uploadPasswordEntry');
+    $('#uploadAuthorizationKeyEntry').val(dict['upload_authorization_key']); markHideIfNull('upload_authorization_key', 'uploadAuthorizationKeyEntry');
+
     /* text overlay */
     $('#textOverlaySwitch')[0].checked = dict['text_overlay']; markHideIfNull('text_overlay', 'textOverlaySwitch');
     $('#leftTextSelect').val(dict['left_text']); markHideIfNull('left_text', 'leftTextSelect');
@@ -2272,6 +2303,34 @@ function doRestore() {
     });
 }
 
+function doTestUpload() {
+    showModalDialog('<div class="modal-progress"></div>', null, null, true);
+    
+    var data = {
+        what: 'upload_service',
+        service: $('#uploadServiceSelect').val(),
+        server: $('#uploadServerEntry').val(),
+        port: $('#uploadPortEntry').val(),
+        method: $('#uploadMethodSelect').val(),
+        location: $('#uploadLocationEntry').val(),
+        username: $('#uploadUsernameEntry').val(),
+        password: $('#uploadPasswordEntry').val(),
+        authorization_key: $('#uploadAuthorizationKeyEntry').val()
+    };
+    
+    var cameraId = $('#cameraSelect').val();
+
+    ajax('POST', basePath + 'config/' + cameraId + '/test/', data, function (data) {
+        hideModalDialog(); /* progress */
+        if (data.error) {
+            showErrorMessage('Accessing the upload service failed: ' + data.error + '!');
+        }
+        else {
+            showPopupMessage('Accessing the upload service succeeded!', 'info');
+        }
+    });
+}
+
 function doDownloadZipped(cameraId, groupKey) {
     showModalDialog('<div class="modal-progress"></div>', null, null, true);
     ajax('GET', basePath + 'picture/' + cameraId + '/zipped/' + groupKey + '/', null, function (data) {
@@ -4022,6 +4081,9 @@ $(document).ready(function () {
     $('div#backupButton').click(doBackup);
     $('div#restoreButton').click(doRestore);
     
+    /* test buttons */
+    $('div#uploadTestButton').click(doTestUpload);
+    
     /* prevent scroll events on settings div from propagating TODO this does not actually work */
     $('div.settings').mousewheel(function (e, d) {
         var t = $(this);
diff --git a/motioneye/tasker.py b/motioneye/tasker.py
deleted file mode 100644 (file)
index f2f6db7..0000000
+++ /dev/null
@@ -1,147 +0,0 @@
-
-# Copyright (c) 2013 Calin Crisan
-# This file is part of motionEye.
-#
-# motionEye is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-# 
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU General Public License for more details.
-# 
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>. 
-
-import calendar
-import cPickle
-import datetime
-import logging
-import multiprocessing
-import os
-import time
-
-from tornado.ioloop import IOLoop
-
-import settings
-
-
-_INTERVAL = 10
-_STATE_FILE_NAME = 'tasks.pickle'
-_MAX_TASKS = 100
-_POOL_SIZE = 2 
-
-_tasks = []
-_pool = None
-
-
-def start():
-    global _pool
-
-    io_loop = IOLoop.instance()
-    io_loop.add_timeout(datetime.timedelta(seconds=_INTERVAL), check_tasks)
-
-    _load()
-    _pool = multiprocessing.Pool(_POOL_SIZE)
-
-
-def check_tasks():
-    io_loop = IOLoop.instance()
-    io_loop.add_timeout(datetime.timedelta(seconds=_INTERVAL), check_tasks)
-    
-    now = time.time()
-    changed = False
-    while _tasks and _tasks[0][0] <= now:
-        (when, func, tag, async, params) = _tasks.pop(0)  # @UnusedVariable
-        
-        logging.debug('executing task "%s"' % tag or func.func_name)
-        if async:
-            _pool.apply_async(func, kwds=params)
-
-        else:
-            try:
-                func(**params)
-            
-            except Exception as e:
-                logging.error('task "%s" failed: %s' % (tag or func.func_name, e), exc_info=True)
-                
-        changed = True
-    
-    if changed:
-        _save()
-
-
-def add_task(when, func, tag=None, async=False, **params):
-    if len(_tasks) >= _MAX_TASKS:
-        return logging.error('the maximum number of tasks (%d) has been reached' % _MAX_TASKS)
-    
-    if isinstance(when, int): # delay, in seconds
-        when += time.time()
-        
-    elif isinstance(when, datetime.timedelta):
-        when = time.time() + when.total_seconds()
-        
-    elif isinstance(when, datetime.datetime):
-        when = calendar.timegm(when.timetuple())
-
-    i = 0
-    while i < len(_tasks) and _tasks[i][0] <= when:
-        i += 1
-
-    logging.debug('adding task "%s" in %d seconds' % (tag or func.func_name, when - time.time()))
-    _tasks.insert(i, (when, func, tag, async, params))
-
-    _save()
-
-
-def _load():
-    global _tasks
-    
-    _tasks = []
-
-    file_path = os.path.join(settings.CONF_PATH, _STATE_FILE_NAME)
-    
-    if os.path.exists(file_path):
-        logging.debug('loading tasks from "%s"...' % file_path)
-    
-        try:
-            file = open(file_path, 'r')
-        
-        except Exception as e:
-            logging.error('could not open tasks file "%s": %s' % (file_path, e))
-            
-            return
-        
-        try:
-            _tasks = cPickle.load(file)
-
-        except Exception as e:
-            logging.error('could not read tasks from file "%s": %s' % (file_path, e))
-
-        finally:
-            file.close()
-            
-
-def _save():
-    file_path = os.path.join(settings.CONF_PATH, _STATE_FILE_NAME)
-
-    logging.debug('saving tasks to "%s"...' % file_path)
-
-    try:
-        file = open(file_path, 'w')
-
-    except Exception as e:
-        logging.error('could not open tasks file "%s": %s' % (file_path, e))
-        
-        return
-
-    try:
-        cPickle.dump(_tasks, file)
-
-    except Exception as e:
-        logging.error('could not save tasks to file "%s": %s'% (file_path, e))
-
-    finally:
-        file.close()
diff --git a/motioneye/tasks.py b/motioneye/tasks.py
new file mode 100644 (file)
index 0000000..85ca7b1
--- /dev/null
@@ -0,0 +1,162 @@
+
+# Copyright (c) 2013 Calin Crisan
+# This file is part of motionEye.
+#
+# motionEye is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# 
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+# 
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>. 
+
+import calendar
+import cPickle
+import datetime
+import logging
+import multiprocessing
+import os
+import time
+
+from tornado.ioloop import IOLoop
+
+import settings
+
+
+_INTERVAL = 10
+_STATE_FILE_NAME = 'tasks.pickle'
+_MAX_TASKS = 100
+_POOL_SIZE = 2 
+
+_tasks = []
+_pool = None
+
+
+def start():
+    global _pool
+
+    io_loop = IOLoop.instance()
+    io_loop.add_timeout(datetime.timedelta(seconds=_INTERVAL), _check_tasks)
+    
+    def init_pool_process():
+        import signal
+
+        signal.signal(signal.SIGINT, signal.SIG_IGN)
+        signal.signal(signal.SIGTERM, signal.SIG_IGN)
+
+    _load()
+    _pool = multiprocessing.Pool(_POOL_SIZE, initializer=init_pool_process)
+
+
+def stop():
+    global _pool
+    
+    #_pool.terminate()
+    _pool = None
+
+
+def add(when, func, tag=None, async=False, **params):
+    if len(_tasks) >= _MAX_TASKS:
+        return logging.error('the maximum number of tasks (%d) has been reached' % _MAX_TASKS)
+    
+    now = time.time()
+    
+    if isinstance(when, int): # delay, in seconds
+        when += now
+        
+    elif isinstance(when, datetime.timedelta):
+        when = now + when.total_seconds()
+        
+    elif isinstance(when, datetime.datetime):
+        when = calendar.timegm(when.timetuple())
+
+    i = 0
+    while i < len(_tasks) and _tasks[i][0] <= when:
+        i += 1
+
+    logging.debug('adding task "%s" in %d seconds' % (tag or func.func_name, when - now))
+    _tasks.insert(i, (when, func, tag, async, params))
+
+    _save()
+
+
+def _check_tasks():
+    io_loop = IOLoop.instance()
+    io_loop.add_timeout(datetime.timedelta(seconds=_INTERVAL), _check_tasks)
+    
+    now = time.time()
+    changed = False
+    while _tasks and _tasks[0][0] <= now:
+        (when, func, tag, async, params) = _tasks.pop(0)  # @UnusedVariable
+        
+        logging.debug('executing task "%s"' % tag or func.func_name)
+        if async:
+            _pool.apply_async(func, kwds=params)
+
+        else:
+            try:
+                func(**params)
+            
+            except Exception as e:
+                logging.error('task "%s" failed: %s' % (tag or func.func_name, e), exc_info=True)
+                
+        changed = True
+    
+    if changed:
+        _save()
+
+
+def _load():
+    global _tasks
+    
+    _tasks = []
+
+    file_path = os.path.join(settings.CONF_PATH, _STATE_FILE_NAME)
+    
+    if os.path.exists(file_path):
+        logging.debug('loading tasks from "%s"...' % file_path)
+    
+        try:
+            file = open(file_path, 'r')
+        
+        except Exception as e:
+            logging.error('could not open tasks file "%s": %s' % (file_path, e))
+            
+            return
+        
+        try:
+            _tasks = cPickle.load(file)
+
+        except Exception as e:
+            logging.error('could not read tasks from file "%s": %s' % (file_path, e))
+
+        finally:
+            file.close()
+            
+
+def _save():
+    file_path = os.path.join(settings.CONF_PATH, _STATE_FILE_NAME)
+
+    logging.debug('saving tasks to "%s"...' % file_path)
+
+    try:
+        file = open(file_path, 'w')
+
+    except Exception as e:
+        logging.error('could not open tasks file "%s": %s' % (file_path, e))
+        
+        return
+
+    try:
+        cPickle.dump(_tasks, file)
+
+    except Exception as e:
+        logging.error('could not save tasks to file "%s": %s'% (file_path, e))
+
+    finally:
+        file.close()
index 0c07200fd2214a82e8c3e9445d0b4bf56bd21c3c..1a9c2e434a44a7a5c63bdd7a4452bc266a8bb40b 100644 (file)
                         <td class="settings-item-label"><span class="settings-item-label">Upload Service</span></td>
                         <td class="settings-item-value">
                             <select class="styled storage camera-config" id="uploadServiceSelect">
-                                <option value="ftp">FTP Server</option>
-                                <option value="sftp">SFTP Server</option>
-                                <option value="http">HTTP Server</option>
-                                <option value="https">HTTPS Server</option>
+<!--                                 <option value="ftp">FTP Server</option> -->
+<!--                                 <option value="sftp">SFTP Server</option> -->
+<!--                                 <option value="http">HTTP Server</option> -->
+<!--                                 <option value="https">HTTPS Server</option> -->
                                 <option value="gdrive">Google Drive</option>
-                                <option value="dropbox">Dropbox</option>
+<!--                                 <option value="dropbox">Dropbox</option> -->
                             </select>
                         </td>
                         <td><span class="help-mark" title="choose a service to which the media files should be uploaded">?</span></td>
                         <td class="settings-item-value"><input type="text" class="number styled storage camera-config" id="uploadPortEntry"></td>
                         <td><span class="help-mark" title="the port to use when connecting to the service (leave it empty to use the default value)">?</span></td>
                     </tr>
-                    <tr class="settings-item advanced-setting" required="true" depends="uploadEnabled uploadService=(ftp|sftp|http|https)">
-                        <td class="settings-item-label"><span class="settings-item-label">Location</span></td>
-                        <td class="settings-item-value"><input type="text" class="styled storage camera-config" id="uploadLocationEntry"></td>
-                        <td><span class="help-mark" title="the location (relative path) where media files should be uploaded (e.g. /files/cam1/)">?</span></td>
-                    </tr>
                     <tr class="settings-item advanced-setting" depends="uploadEnabled uploadService=(http|https)">
                         <td class="settings-item-label"><span class="settings-item-label">Method</span></td>
                         <td class="settings-item-value">
-                            <select class="styled storage camera-config" id="uploadMethod">
+                            <select class="styled storage camera-config" id="uploadMethodSelect">
                                 <option value="post">POST</option>
                                 <option value="put">PUT</option>
                             </select>
                         </td>
                         <td><span class="help-mark" title="the HTTP method to use when uploading files">?</span></td>
                     </tr>
+                    <tr class="settings-item advanced-setting" required="true" depends="uploadEnabled">
+                        <td class="settings-item-label"><span class="settings-item-label">Location</span></td>
+                        <td class="settings-item-value"><input type="text" class="styled storage camera-config" id="uploadLocationEntry"></td>
+                        <td><span class="help-mark" title="the location (relative path) where media files should be uploaded (e.g. /files/cam1/)">?</span></td>
+                    </tr>
                     <tr class="settings-item advanced-setting" depends="uploadEnabled uploadService=(ftp|sftp|http|https)">
                         <td class="settings-item-label"><span class="settings-item-label">Username</span></td>
                         <td class="settings-item-value"><input type="text" class="styled storage camera-config" id="uploadUsernameEntry"></td>
                         <td class="settings-item-value"><input type="password" class="styled storage camera-config" id="uploadPasswordEntry"></td>
                         <td><span class="help-mark" title="the password for the upload service account">?</span></td>
                     </tr>
-                    <tr class="settings-item advanced-setting" required="true" depends="uploadEnabled uploadService=(gdrive|dropbox)">
+                    <tr class="settings-item advanced-setting" depends="uploadEnabled uploadService=(gdrive|dropbox)">
                         <td class="settings-item-label"><span class="settings-item-label">Authorization Key</span></td>
-                        <td class="settings-item-value"><input type="password" class="styled storage camera-config" id="uploadAuthKey"></td>
-                        <td><span class="help-mark" title="the key used to authenticate with the upload service">?</span></td>
+                        <td class="settings-item-value"><input type="password" class="styled storage camera-config" id="uploadAuthorizationKeyEntry"></td>
+                        <td><span class="help-mark" title="the key used to authenticate with the upload service (normally required only during setup)">?</span></td>
                     </tr>
                     <tr class="settings-item advanced-setting" depends="uploadEnabled uploadService=(gdrive|dropbox)">
                         <td class="settings-item-label"><span class="settings-item-label"></span></td>
                         <td class="settings-item-value">
                             <div class="html styled storage camera-config" id="authorizeLinkHtml">
-                                <a href="javascript:authorizeUpload()">Obtain Authorization Key</a>
+                                <a href="javascript:authorizeUpload()">Obtain Key</a>
                             </div>
                         </td>
                         <td><span class="help-mark" title="click here to obtain the service authorization key">?</span></td>
                     </tr>
+                    <tr class="settings-item advanced-setting" depends="uploadEnabled">
+                        <td class="settings-item-label"><span class="settings-item-label"></span></td>
+                        <td class="settings-item-value"><div class="button normal-button test-button" id="uploadTestButton">Test Service</div></td>
+                        <td><span class="help-mark" title="click this button to test the upload service after you have filled in the required details">?</span></td>
+                    </tr>
                     {% for config in camera_sections.get('storage', {}).get('configs', []) %}
                         {{config_item(config)}}
                     {% endfor %}
diff --git a/motioneye/uploadservices.py b/motioneye/uploadservices.py
new file mode 100644 (file)
index 0000000..2e427ca
--- /dev/null
@@ -0,0 +1,362 @@
+
+# Copyright (c) 2013 Calin Crisan
+# This file is part of motionEye.
+#
+# motionEye is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# 
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+# 
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import httplib2
+import json
+import logging
+import mimetypes
+import os.path
+import urllib
+import urllib2 
+
+from oauth2client.client import OAuth2WebServerFlow, Credentials
+
+import settings
+
+
+_STATE_FILE_NAME = 'uploadservices.json'
+
+_services = {}
+
+
+class UploadService(object):
+    NAME = 'base'
+
+    def __init__(self, **kwargs):
+        pass
+
+    def __str__(self):
+        return self.NAME
+    
+    def get_authorize_url(self):
+        return '/'
+    
+    def test_access(self):
+        return True
+
+    def upload_file(self, filename):
+        self.debug('uploading file "%s" to %s' % (filename, self))
+        
+        try:
+            st = os.stat(filename)
+        
+        except Exception as e:
+            msg = 'failed to open file "%s": %s' % (filename, e)
+            self.error(msg)
+            raise Exception(msg)
+         
+        if st.st_size > self.MAX_FILE_SIZE:
+            msg = 'file "%s" is too large (%sMB/%sMB)' % (filename, st.st_size / 1024 / 1024, self.MAX_FILE_SIZE / 1024 / 1024)
+            self.error(msg)
+            raise Exception(msg)
+
+        try:
+            f = open(filename)
+            
+        except Exception as e:
+            msg = 'failed to open file "%s": %s' % (filename, e)
+            self.error(msg)
+            raise Exception(msg)
+
+        data = f.read()
+        self.debug('size of "%s" is %.1fMB' % (filename, len(data) / 1024.0 / 1024))
+        
+        mime_type = mimetypes.guess_type(filename)[0] or 'image/jpeg'
+        self.debug('mime type of "%s" is "%s"' % (filename, mime_type))
+
+        self.upload_data(filename, mime_type, data)
+
+    def upload_data(self, filename, mime_type, data):
+        pass
+    
+    def dump(self):
+        return {}
+    
+    def load(self, data):
+        pass
+
+    def log(self, level, *args, **kwargs):
+        logging.log(level, *args, **kwargs)
+
+    def debug(self, *args, **kwargs):
+        self.log(logging.DEBUG, *args, **kwargs)
+
+    def info(self, *args, **kwargs):
+        self.log(logging.INFO, *args, **kwargs)
+
+    def error(self, *args, **kwargs):
+        self.log(logging.ERROR, *args, **kwargs)
+        
+    @staticmethod
+    def get_service_classes():
+        return {c.NAME: c for c in UploadService.__subclasses__()}
+
+
+class GoogleDrive(UploadService):
+    NAME = 'gdrive'
+    CLIENT_ID = '349038943026-m16svdadjrqc0c449u4qv71v1m1niu5o.apps.googleusercontent.com'
+    CLIENT_NOT_SO_SECRET = 'jjqbWmICpA0GvbhsJB3okX7s'
+    SCOPE = 'https://www.googleapis.com/auth/drive'
+    CHILDREN_URL = 'https://www.googleapis.com/drive/v2/files/%(parent_id)s/children?q=%(query)s'
+    CHILDREN_QUERY = "'%(parent_id)s' in parents and title = '%(child_name)s'"
+    UPLOAD_URL = 'https://www.googleapis.com/upload/drive/v2/files?uploadType=multipart'
+    BOUNDARY = 'motioneye_multipart_boundary'
+    MAX_FILE_SIZE = 128 * 1024 * 1024 # 128 MB
+
+    def __init__(self, location=None, authorization_key=None, credentials=None, **kwargs):
+        self._location = location
+        self._authorization_key = authorization_key
+        self._credentials = credentials
+        self._folder_id = None
+
+    def get_authorize_url(self):
+        flow = OAuth2WebServerFlow(client_id=self.CLIENT_ID, client_secret=self.CLIENT_NOT_SO_SECRET,
+                scope='https://www.googleapis.com/auth/drive', redirect_uri='urn:ietf:wg:oauth:2.0:oob')
+
+        return flow.step1_get_authorize_url()
+
+    def test_access(self):
+        try:
+            self._folder_id = None
+            self._get_folder_id()
+            return True
+
+        except Exception as e:
+            return str(e)
+
+    def upload_data(self, filename, mime_type, data):
+        metadata = {
+            'title': filename,
+            'parents': [{'id': self._get_folder_id()}]
+        }
+
+        body = ['--' + self.BOUNDARY]
+        body.append('Content-Type: application/json; charset=UTF-8')
+        body.append('')
+        body.append(json.dumps(metadata))
+        body.append('')
+        
+        body.append('--' + self.BOUNDARY)
+        body.append('Content-Type: %s' % mime_type)
+        body.append('')
+        body.append('')
+        body = '\r\n'.join(body)
+        body += data
+        body += '\r\n--%s--' % self.BOUNDARY
+        
+        headers = {
+            'Content-Type': 'multipart/related; boundary="%s"' % self.BOUNDARY,
+            'Content-Length': len(body)
+        }
+        
+        self._request(self.UPLOAD_URL, body, headers)
+
+    def dump(self):
+        return {
+            'location': self._location,
+            'credentials': self._credentials and json.loads(self._credentials.to_json()),
+            'authorization_key': self._authorization_key,
+            'folder_id': self._folder_id
+        }
+
+    def load(self, data):
+        if 'location' in data:
+            self._location = data['location']
+            self._folder_id = None # invalidate the folder
+        if 'credentials' in data:
+            self._credentials = Credentials.new_from_json(json.dumps(data['credentials']))
+        if 'authorization_key' in data:
+            self._authorization_key = data['authorization_key']
+        if 'folder_id' in data:
+            self._folder_id = data['folder_id']
+
+    def _get_folder_id(self):
+        if not self._folder_id:
+            self.debug('finding folder id for location "%s"' % self._location)
+            self._folder_id = self._get_folder_id_by_path(self._location)
+            save()
+
+        return self._folder_id
+
+    def _get_folder_id_by_path(self, path):
+        path = [p.strip() for p in path.split('/') if p.strip()]
+
+        parent_id = 'root'
+        for name in path:
+            parent_id = self._get_folder_id_by_name(parent_id, name)
+
+        return parent_id
+
+    def _get_folder_id_by_name(self, parent_id, child_name):
+        query = self.CHILDREN_QUERY % {'parent_id': parent_id, 'child_name': child_name}
+        query = urllib.quote(query)
+        url = self.CHILDREN_URL % {'parent_id': parent_id, 'query': query}
+        response = self._request(url)
+        try:
+            response = json.loads(response)
+
+        except Exception:
+            self.error("response doesn't seem to be a valid json")
+            raise
+
+        items = response.get('items')
+        if not items:
+            msg = 'folder with name "%s" could not be found' % child_name
+            self.error(msg)
+            raise Exception(msg)
+        
+        return items[0]['id']
+
+    def _request(self, url, body=None, headers=None):
+        if not self._authorization_key:
+            msg = 'missing authorization key'
+            self.error(msg)
+            raise Exception(msg)
+
+        if not self._credentials:
+            self.debug('requesting access token')
+            flow = self._get_oauth2_flow()
+            try:
+                self._credentials = flow.step2_exchange(self._authorization_key)
+                save()
+            
+            except Exception as e:
+                self.error('failed to obtain access token: %s' % e)
+                raise
+
+        headers = headers or {}
+        headers['Authorization'] = 'Bearer %s' % self._credentials.access_token
+
+        self.debug('requesting %s' % url)
+        request = urllib2.Request(url, data=body, headers=headers)
+        try:
+            response = urllib2.urlopen(request)
+        
+        except urllib2.HTTPError as e:
+            if e.code == 403: # unauthorized, access token may have expired
+                try:
+                    self.debug('access token might have expired, refreshing it')
+                    self._credentials.refresh(httplib2.Http())
+                    save()
+
+                except Exception as e:
+                    self.error('refreshing access token failed')
+                    raise
+                
+            else:
+                self.error('request failed: %s' % e)
+                raise
+
+        except Exception as e:
+            self.error('request failed: %s' % e)
+            raise
+
+        return response.read()
+
+    def _get_oauth2_flow(self):
+        return OAuth2WebServerFlow(client_id=self.CLIENT_ID, client_secret=self.CLIENT_NOT_SO_SECRET,
+                scope=self.SCOPE, redirect_uri='urn:ietf:wg:oauth:2.0:oob')
+
+
+def get(camera_id, name, create=True):
+    camera_id = str(camera_id)
+    service = _services.get(camera_id, {}).get(name)
+    if not service and create:
+        classes = UploadService.get_service_classes()
+        cls = classes.get(name)
+        if cls:
+            logging.debug('creating upload service %s for camera with id %s' % (name, camera_id))
+            service = cls()
+            _services.setdefault(camera_id, {})[name] = service
+
+    return service
+
+
+def load():
+    file_path = os.path.join(settings.CONF_PATH, _STATE_FILE_NAME)
+    
+    if os.path.exists(file_path):
+        logging.debug('loading upload services state from "%s"...' % file_path)
+    
+        try:
+            file = open(file_path, 'r')
+        
+        except Exception as e:
+            logging.error('could not open upload services state file "%s": %s' % (file_path, e))
+            
+            return
+
+        try:
+            data = json.load(file)
+
+        except Exception as e:
+            return logging.error('could not read upload services state from file "%s": %s'(file_path, e))
+
+        finally:
+            file.close()
+
+        for camera_id, d in data.iteritems():
+            for name, state in d.iteritems():
+                camera_services = _services.setdefault(camera_id, {})
+                cls = UploadService.get_service_classes().get(name)
+                if cls:
+                    service = cls()
+                    service.load(state)
+
+                    camera_services[name] = service
+    
+                    logging.debug('loaded upload service "%s" for camera with id "%s"' % (name, camera_id))
+
+
+def save():
+    file_path = os.path.join(settings.CONF_PATH, _STATE_FILE_NAME)
+    
+    logging.debug('saving upload services state to "%s"...' % file_path)
+
+    try:
+        file = open(file_path, 'w')
+
+    except Exception as e:
+        logging.error('could not open upload services state file "%s": %s' % (file_path, e))
+        
+        return
+    
+    data = {}
+    for camera_id, camera_services in _services.iteritems():
+        for name, service in camera_services.iteritems():
+            data.setdefault(camera_id, {})[name] = service.dump()
+
+    try:
+        json.dump(data, file, sort_keys=True, indent=4)
+
+    except Exception as e:
+        logging.error('could not save upload services state to file "%s": %s'(file_path, e))
+
+    finally:
+        file.close()
+
+
+def upload_media_file(camera_id, service_name, filename):
+    service = get(camera_id, service_name, create=False)
+    if not service:
+        return logging.error('service "%s" not initialized for camera with id %s' % (service_name, camera_id))
+
+    try:
+        service.upload_file(filename)
+
+    except Exception as e:
+        logging.error('failed to upload file "%s" with service %s: %s' % (filename, service, e))