]> www.vanbest.org Git - motioneye-debian/commitdiff
added support for uploading media files to Dropbox
authorCalin Crisan <ccrisan@gmail.com>
Wed, 25 Nov 2015 21:22:35 +0000 (23:22 +0200)
committerCalin Crisan <ccrisan@gmail.com>
Wed, 25 Nov 2015 21:22:35 +0000 (23:22 +0200)
extra/motioneye-256x256.png [new file with mode: 0644]
extra/motioneye-64x64.png [new file with mode: 0644]
extra/motioneye.svg [new file with mode: 0644]
motioneye/config.py
motioneye/handlers.py
motioneye/static/js/main.js
motioneye/templates/main.html
motioneye/uploadservices.py

diff --git a/extra/motioneye-256x256.png b/extra/motioneye-256x256.png
new file mode 100644 (file)
index 0000000..e2dde4c
Binary files /dev/null and b/extra/motioneye-256x256.png differ
diff --git a/extra/motioneye-64x64.png b/extra/motioneye-64x64.png
new file mode 100644 (file)
index 0000000..6aedd84
Binary files /dev/null and b/extra/motioneye-64x64.png differ
diff --git a/extra/motioneye.svg b/extra/motioneye.svg
new file mode 100644 (file)
index 0000000..69f3c8b
--- /dev/null
@@ -0,0 +1,67 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   id="svg2"
+   version="1.1"
+   inkscape:version="0.91 r13725"
+   width="64"
+   height="64"
+   xml:space="preserve"
+   sodipodi:docname="motioneye.svg"
+   inkscape:export-filename="/media/data/projects/motioneyeos/resources/motioneye-256x256.png"
+   inkscape:export-xdpi="360"
+   inkscape:export-ydpi="360"><metadata
+     id="metadata8"><rdf:RDF><cc:Work
+         rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title /></cc:Work></rdf:RDF></metadata><defs
+     id="defs6" /><sodipodi:namedview
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1"
+     objecttolerance="10"
+     gridtolerance="10"
+     guidetolerance="10"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:window-width="1920"
+     inkscape:window-height="1018"
+     id="namedview4"
+     showgrid="false"
+     inkscape:zoom="1"
+     inkscape:cx="31.099761"
+     inkscape:cy="76.252821"
+     inkscape:window-x="0"
+     inkscape:window-y="25"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="g10"
+     showguides="true"
+     inkscape:guide-bbox="true"
+     inkscape:showpageshadow="false"
+     inkscape:snap-bbox="true"
+     inkscape:bbox-nodes="true"
+     inkscape:bbox-paths="true"
+     inkscape:snap-bbox-edge-midpoints="true"
+     inkscape:snap-bbox-midpoints="true"
+     inkscape:object-paths="true"
+     inkscape:snap-intersection-paths="true"
+     inkscape:object-nodes="true"
+     inkscape:snap-midpoints="true"
+     inkscape:snap-smooth-nodes="true" /><g
+     id="g10"
+     inkscape:groupmode="layer"
+     inkscape:label="ink_ext_XXXXXX"
+     transform="matrix(1.25,0,0,-1.25,0,64)"><path
+       style="fill:#3498db;fill-opacity:1;stroke:none"
+       d="m 32,6 c -5.264933,0 -10.15584,1.684955 -14.25,4.375 4.426748,0.474546 8.794165,1.616509 13.75,4.0625 5.014819,-2.5549 10.152222,-3.621792 14.78125,-4.15625 C 42.179879,7.5768125 37.280255,6 32,6 z m 17,5.59375 c -5.21085,0.03453 -12.330126,1.666208 -17.5,4.03125 -5.435679,-1.964064 -11.012887,-4.147356 -17.3125,-4 -0.419974,0.0099 -0.823167,0.03114 -1.25,0.0625 L 8,12.09375 l 4.6875,1.5625 c 6.429941,2.095118 12.084993,4.395085 17.0625,13.03125 1.154473,0 3.425649,5e-4 4.5,5e-4 4.957862,-8.587269 10.904708,-10.983331 17.03125,-13.03175 l 4.625,-1.5 -4.8125,-0.46875 C 50.437916,11.625136 49.744407,11.588818 49,11.59375 z M 12.65625,14.6875 C 8.5126665,19.293305 6,25.316864 6,32 6,46.359404 17.640596,58 32,58 46.359404,58 58,46.359404 58,32 58,25.341351 55.491547,19.287285 51.375,14.6875 49.406504,15.410957 47.442694,16.253379 45.5,17.34375 49.260018,18.43176 52,21.91463 52,26 c 0,4.945036 -4.027245,9 -9,9 -4.217962,0 -7.765819,-2.914031 -8.75,-6.8125 -1.243768,0 -3.31109,0 -4.5,0 C 28.774204,32.087724 25.219217,35 21,35 c -4.972755,0 -9.03125,-4.054964 -9.03125,-9 0,-4.133444 2.851912,-7.644 6.6875,-8.6875 -1.945667,-1.058808 -3.952016,-1.881003 -6,-2.625 z M 21,23 c -1.656854,0 -3,1.343146 -3,3 0,1.656854 1.343146,3 3,3 1.656854,0 3,-1.343146 3,-3 0,-1.656854 -1.343146,-3 -3,-3 z m 22,0 c -1.656854,0 -3,1.343146 -3,3 0,1.656854 1.343146,3 3,3 1.656854,0 3,-1.343146 3,-3 0,-1.656854 -1.343146,-3 -3,-3 z m -11,8 c 0.618234,2.299881 1.278866,4.581981 4,6 l -4,6 -4,-6 c 2.383634,-1.6888 3.483037,-3.758112 4,-6 z"
+       id="path3938"
+       inkscape:connector-curvature="0"
+       sodipodi:nodetypes="scccssccccccccccscsssccssccssccssssssssssccccc"
+       transform="matrix(0.8,0,0,-0.8,0,51.2)" /></g></svg>
\ No newline at end of file
index 5646ed599c8434ac25a2dc258d47b2f9688b309c..69a4ae146d0373095caa45487e3ebe4a0bb387c7 100644 (file)
@@ -643,6 +643,7 @@ def motion_camera_ui_to_dict(ui, old_config=None):
         '@upload_port': ui['upload_port'],
         '@upload_method': ui['upload_method'],
         '@upload_location': ui['upload_location'],
+        '@upload_subfolders': ui['upload_subfolders'],
         '@upload_username': ui['upload_username'],
         '@upload_password': ui['upload_password'],
         '@upload_authorization_key': ui['upload_authorization_key'],
@@ -959,6 +960,7 @@ def motion_camera_dict_to_ui(data):
         'upload_port': data['@upload_port'],
         'upload_method': data['@upload_method'],
         'upload_location': data['@upload_location'],
+        'upload_subfolders': data['@upload_subfolders'],
         'upload_username': data['@upload_username'],
         'upload_password': data['@upload_password'],
         'upload_authorization_key': data['@upload_authorization_key'],
@@ -1655,6 +1657,7 @@ def _set_default_motion_camera(camera_id, data):
     data.setdefault('@upload_port', '')
     data.setdefault('@upload_method', 'POST')
     data.setdefault('@upload_location', '')
+    data.setdefault('@upload_subfolders', True)
     data.setdefault('@upload_username', '')
     data.setdefault('@upload_password', '')
     data.setdefault('@upload_authorization_key', '')
index 3042f7e3b709b2661db682140a4e06e846ad1dc5..bd59d8faee06debf0b183d05e86828e7c0dff590 100644 (file)
@@ -1492,23 +1492,27 @@ class RelayEventHandler(BaseHandler):
 
             # 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)
+                self.upload_media_file(filename, camera_id, camera_config)
 
         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)
+                self.upload_media_file(filename, camera_id, camera_config)
 
         else:
             logging.warn('unknown event %s' % event)
 
         self.finish_json()
+    
+    def upload_media_file(self, filename, camera_id, camera_config):
+        service_name = camera_config['@upload_service']
+
+        tasks.add(5, uploadservices.upload_media_file, tag='upload_media_file(%s)' % filename, async=True,
+                camera_id=camera_id, service_name=service_name,
+                target_dir=camera_config['@upload_subfolders'] and camera_config['target_dir'],
+                filename=filename)
 
 
 class LogHandler(BaseHandler):
index 5174eea328e025f2972ebeaaebbaa0aac2e4eaee..e6f9cc0c6e094c27e1fb19b623e689e9bceeb442 100644 (file)
@@ -1370,6 +1370,7 @@ function cameraUi2Dict() {
         'upload_port': $('#uploadPortEntry').val(),
         'upload_method': $('#uploadMethodSelect').val(),
         'upload_location': $('#uploadLocationEntry').val(),
+        'upload_subfolders': $('#uploadSubfoldersSwitch')[0].checked,
         'upload_username': $('#uploadUsernameEntry').val(),
         'upload_password': $('#uploadPasswordEntry').val(),
         'upload_authorization_key': $('#uploadAuthorizationKeyEntry').val(),
@@ -1656,6 +1657,7 @@ function dict2CameraUi(dict) {
     $('#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');
+    $('#uploadSubfoldersSwitch')[0].checked = dict['upload_subfolders']; markHideIfNull('upload_subfolders', 'uploadSubfoldersSwitch');
     $('#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');
@@ -2304,6 +2306,19 @@ function doRestore() {
 }
 
 function doTestUpload() {
+    var q = $('#uploadPortEntry, #uploadLocationEntry, #uploadServerEntry');
+    var valid = true;
+    q.each(function() {
+        this.validate();
+        if (this.invalid) {
+            valid = false;
+        }
+    });
+    
+    if (!valid) {
+        return runAlertDialog('Make sure all the configuration options are valid!');
+    }
+    
     showModalDialog('<div class="modal-progress"></div>', null, null, true);
     
     var data = {
@@ -2313,6 +2328,7 @@ function doTestUpload() {
         port: $('#uploadPortEntry').val(),
         method: $('#uploadMethodSelect').val(),
         location: $('#uploadLocationEntry').val(),
+        subfolders: $('#uploadSubfoldersSwitch')[0].checked,
         username: $('#uploadUsernameEntry').val(),
         password: $('#uploadPasswordEntry').val(),
         authorization_key: $('#uploadAuthorizationKeyEntry').val()
index 1a9c2e434a44a7a5c63bdd7a4452bc266a8bb40b..6daef16d8aca45d6b65af676ad7e32219ad452e3 100644 (file)
 <!--                                 <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>
                     <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>
+                        <td><span class="help-mark" title="the location (root path) where media files should be uploaded (e.g. /files/cam1/)">?</span></td>
+                    </tr>
+                    <tr class="settings-item advanced-setting" depends="uploadEnabled uploadService=(dropbox)">
+                        <td class="settings-item-label"><span class="settings-item-label">Include Subfolders</span></td>
+                        <td class="settings-item-value"><input type="checkbox" class="styled storage camera-config" id="uploadSubfoldersSwitch"></td>
+                        <td><span class="help-mark" title="enable this to upload the media files together with their subfolders, instead of placing them directly into the root location">?</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>
index 3f520d4e256be6b5fd886355e5cba28af144eb2f..f0cc0c4a0378d3064b3672290bc741681ea4b8d5 100644 (file)
@@ -15,7 +15,6 @@
 # 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
@@ -23,14 +22,12 @@ import os.path
 import urllib
 import urllib2 
 
-from oauth2client.client import OAuth2WebServerFlow, Credentials
-
 import settings
 
 
 _STATE_FILE_NAME = 'uploadservices.json'
 
-_services = {}
+_services = None
 
 
 class UploadService(object):
@@ -48,8 +45,20 @@ class UploadService(object):
     def test_access(self):
         return True
 
-    def upload_file(self, filename):
-        self.debug('uploading file "%s" to %s' % (filename, self))
+    def upload_file(self, target_dir, filename):
+        if target_dir:
+            target_dir = os.path.realpath(target_dir)
+            rel_filename = filename[len(target_dir):]
+            
+            while rel_filename.startswith('/'):
+                rel_filename = rel_filename[1:]
+
+            self.debug('uploading file "[%s]/%s" to %s' % (target_dir, rel_filename, self))
+
+        else:
+            rel_filename = os.path.basename(filename)
+            
+            self.debug('uploading file "%s" to %s' % (filename, self))
         
         try:
             st = os.stat(filename)
@@ -78,7 +87,9 @@ class UploadService(object):
         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)
+        self.upload_data(rel_filename, mime_type, data)
+        
+        self.debug('file "%s" successfully uploaded' % filename)
 
     def upload_data(self, filename, mime_type, data):
         pass
@@ -108,12 +119,18 @@ class UploadService(object):
 
 class GoogleDrive(UploadService):
     NAME = 'gdrive'
+    
+    AUTH_URL = 'https://accounts.google.com/o/oauth2/auth'
+    TOKEN_URL = 'https://accounts.google.com/o/oauth2/token'
+
     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
 
@@ -124,10 +141,15 @@ class GoogleDrive(UploadService):
         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')
+        query = {
+            'scope': self.SCOPE,
+            'redirect_uri': 'urn:ietf:wg:oauth:2.0:oob',
+            'response_type': 'code',
+            'client_id': self.CLIENT_ID,
+            'access_type': 'offline'
+        }
 
-        return flow.step1_get_authorize_url()
+        return self.AUTH_URL + '?' + urllib.urlencode(query)
 
     def test_access(self):
         try:
@@ -168,7 +190,7 @@ class GoogleDrive(UploadService):
     def dump(self):
         return {
             'location': self._location,
-            'credentials': self._credentials and json.loads(self._credentials.to_json()),
+            'credentials': self._credentials,
             'authorization_key': self._authorization_key,
             'folder_id': self._folder_id
         }
@@ -178,7 +200,7 @@ class GoogleDrive(UploadService):
             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']))
+            self._credentials = data['credentials']
         if 'authorization_key' in data:
             self._authorization_key = data['authorization_key']
         if 'folder_id' in data:
@@ -193,17 +215,29 @@ class GoogleDrive(UploadService):
         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)
+        if path and 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
 
-        return parent_id
+        else: # root folder
+            return self._get_folder_id_by_name(None, 'root')
 
     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)
+        if parent_id:
+            query = self.CHILDREN_QUERY % {'parent_id': parent_id, 'child_name': child_name}
+            query = urllib.quote(query)
+            
+        else:
+            query = ''
+            
+        parent_id = parent_id or 'root'
+        # when requesting the id of the root folder, we perform a dummy request,
+        # event though we already know the id (which is "root"), to test the request 
+
         url = self.CHILDREN_URL % {'parent_id': parent_id, 'query': query}
         response = self._request(url)
         try:
@@ -212,6 +246,9 @@ class GoogleDrive(UploadService):
         except Exception:
             self.error("response doesn't seem to be a valid json")
             raise
+        
+        if parent_id == 'root' and child_name == 'root':
+            return 'root'
 
         items = response.get('items')
         if not items:
@@ -228,19 +265,229 @@ class GoogleDrive(UploadService):
             raise Exception(msg)
 
         if not self._credentials:
-            self.debug('requesting access token')
-            flow = self._get_oauth2_flow()
+            self.debug('requesting credentials')
             try:
-                self._credentials = flow.step2_exchange(self._authorization_key)
+                self._credentials = self._request_credentials(self._authorization_key)
                 save()
             
             except Exception as e:
-                self.error('failed to obtain access token: %s' % e)
+                self.error('failed to obtain credentials: %s' % e)
                 raise
 
         headers = headers or {}
-        headers['Authorization'] = 'Bearer %s' % self._credentials.access_token
+        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 == 401 and retry_auth: # unauthorized, access token may have expired
+                try:
+                    self.debug('credentials have probably expired, refreshing them')
+                    self._credentials = self._refresh_credentials(self._credentials['refresh_token'])
+                    save()
+                    
+                    # retry the request with refreshed credentials
+                    return self._request(url, body, headers, retry_auth=False)
+
+                except Exception as e:
+                    self.error('refreshing credentials failed')
+                    raise
+                
+            else:
+                try:
+                    e = json.load(e)
+                    msg = e['error']['message']
+                
+                except Exception:
+                    msg = str(e)
+                    
+                self.error('request failed: %s' % msg)
+                raise Exception(msg)
+
+        except Exception as e:
+            self.error('request failed: %s' % e)
+            raise
+
+        return response.read()
+    
+    def _request_credentials(self, authorization_key):
+        headers = {
+            'Content-Type': 'application/x-www-form-urlencoded'
+        }
+        
+        body = {
+            'code': authorization_key,
+            'redirect_uri': 'urn:ietf:wg:oauth:2.0:oob',
+            'client_id': self.CLIENT_ID, 
+            'client_secret': self.CLIENT_NOT_SO_SECRET,
+            'scope': self.SCOPE,
+            'grant_type': 'authorization_code'
+        }
+        body = urllib.urlencode(body)
+
+        request = urllib2.Request(self.TOKEN_URL, data=body, headers=headers)
+        
+        try:
+            response = urllib2.urlopen(request)
+        
+        except urllib2.HTTPError as e:
+            error = json.load(e)
+            raise Exception(error.get('error_description') or error.get('error') or str(e))
+        
+        data = json.load(response)
+        
+        return {
+            'access_token': data['access_token'],
+            'refresh_token': data['refresh_token']
+        }
+    
+    def _refresh_credentials(self, refresh_token):
+        headers = {
+            'Content-Type': 'application/x-www-form-urlencoded'
+        }
+
+        body = {
+            'refresh_token': refresh_token,
+            'client_id': self.CLIENT_ID, 
+            'client_secret': self.CLIENT_NOT_SO_SECRET,
+            'grant_type': 'refresh_token'
+        }
+        body = urllib.urlencode(body)
 
+        request = urllib2.Request(self.TOKEN_URL, data=body, headers=headers)
+        
+        try:
+            response = urllib2.urlopen(request)
+        
+        except urllib2.HTTPError as e:
+            error = json.load(e)
+            raise Exception(error.get('error_description') or error.get('error') or str(e))
+
+        data = json.load(response)
+        
+        return {
+            'access_token': data['access_token'],
+            'refresh_token': data.get('refresh_token', refresh_token)
+        }
+
+
+class Dropbox(UploadService):
+    NAME = 'dropbox'
+    
+    AUTH_URL = 'https://www.dropbox.com/1/oauth2/authorize'
+    TOKEN_URL = 'https://api.dropboxapi.com/1/oauth2/token'
+
+    CLIENT_ID = 'dwiw710jz6r60pq'
+    CLIENT_NOT_SO_SECRET = '8jz75qo405ritd5'
+    
+    LIST_FOLDER_URL = 'https://api.dropboxapi.com/2/files/list_folder'
+    UPLOAD_URL = 'https://content.dropboxapi.com/2/files/upload'
+
+    MAX_FILE_SIZE = 128 * 1024 * 1024 # 128 MB
+
+    def __init__(self, location=None, subfolders=True, authorization_key=None, credentials=None, **kwargs):
+        self._location = location
+        self._subfolders = subfolders
+        self._authorization_key = authorization_key
+        self._credentials = credentials
+
+    def get_authorize_url(self):
+        query = {
+            'response_type': 'code',
+            'client_id': self.CLIENT_ID
+        }
+
+        return self.AUTH_URL + '?' + urllib.urlencode(query)
+
+    def test_access(self):
+        body = {
+            'path': self._clean_location(),
+            'recursive': False,
+            'include_media_info': False,
+            'include_deleted': False
+        }
+        
+        body = json.dumps(body)
+        headers = {'Content-Type': 'application/json'}
+        
+        try:
+            self._request(self.LIST_FOLDER_URL, body, headers)
+            return True
+
+        except Exception as e:
+            msg = str(e)
+            
+            # remove trailing punctuation
+            while msg and not msg[-1].isalnum():
+                msg = msg[:-1]
+            
+            return msg
+
+    def upload_data(self, filename, mime_type, data):
+        metadata = {
+            'path': os.path.join(self._clean_location(), filename),
+            'mode': 'add',
+            'autorename': True,
+            'mute': False
+        }
+
+        headers = {
+            'Content-Type': 'application/octet-stream',
+            'Dropbox-API-Arg': json.dumps(metadata)
+        }
+
+        self._request(self.UPLOAD_URL, data, headers)
+
+    def dump(self):
+        return {
+            'location': self._location,
+            'subfolders': self._subfolders,
+            'credentials': self._credentials,
+            'authorization_key': self._authorization_key
+        }
+
+    def load(self, data):
+        if 'location' in data:
+            self._location = data['location']
+        if 'subfolders' in data:
+            self._subfolders = data['subfolders']
+        if 'credentials' in data:
+            self._credentials = data['credentials']
+        if 'authorization_key' in data:
+            self._authorization_key = data['authorization_key']
+    
+    def _clean_location(self):
+        location = self._location
+        if location == '/':
+            return ''
+        
+        if not location.startswith('/'):
+            location = '/' + location
+        
+        return location
+
+    def _request(self, url, body=None, headers=None, retry_auth=True):
+        if not self._authorization_key:
+            msg = 'missing authorization key'
+            self.error(msg)
+            raise Exception(msg)
+
+        if not self._credentials:
+            self.debug('requesting credentials')
+            try:
+                self._credentials = self._request_credentials(self._authorization_key)
+                save()
+            
+            except Exception as e:
+                self.error('failed to obtain credentials: %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:
@@ -249,15 +496,22 @@ class GoogleDrive(UploadService):
         except urllib2.HTTPError as e:
             if e.code == 401 and retry_auth: # unauthorized, access token may have expired
                 try:
-                    self.debug('access token has probably expired, refreshing it')
-                    self._credentials.refresh(httplib2.Http())
+                    self.debug('credentials have probably expired, refreshing them')
+                    self._credentials = self._refresh_credentials(self._credentials['refresh_token'])
                     save()
+                    
+                    # retry the request with refreshed credentials
                     self._request(url, body, headers, retry_auth=False)
 
                 except Exception as e:
-                    self.error('refreshing access token failed')
+                    self.error('refreshing credentials failed')
                     raise
-                
+            
+            elif str(e).count('not_found'):
+                msg = 'folder "%s" not found' % self._location
+                self.error(msg)
+                raise Exception(msg)
+            
             else:
                 self.error('request failed: %s' % e)
                 raise
@@ -267,13 +521,40 @@ class GoogleDrive(UploadService):
             raise
 
         return response.read()
+    
+    def _request_credentials(self, authorization_key):
+        headers = {
+            'Content-Type': 'application/x-www-form-urlencoded'
+        }
+        
+        body = {
+            'code': authorization_key,
+            'client_id': self.CLIENT_ID, 
+            'client_secret': self.CLIENT_NOT_SO_SECRET,
+            'grant_type': 'authorization_code'
+        }
+        body = urllib.urlencode(body)
 
-    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')
-
+        request = urllib2.Request(self.TOKEN_URL, data=body, headers=headers)
+        
+        try:
+            response = urllib2.urlopen(request)
+        
+        except urllib2.HTTPError as e:
+            error = json.load(e)
+            raise Exception(error.get('error_description') or error.get('error') or str(e))
+        
+        data = json.load(response)
+        
+        return {
+            'access_token': data['access_token']
+        }
+    
 
 def get(camera_id, name, create=True):
+    if _services is None:
+        load()
+    
     camera_id = str(camera_id)
     service = _services.get(camera_id, {}).get(name)
     if not service and create:
@@ -288,6 +569,10 @@ def get(camera_id, name, create=True):
 
 
 def load():
+    global _services
+    
+    _services = {}
+    
     file_path = os.path.join(settings.CONF_PATH, _STATE_FILE_NAME)
     
     if os.path.exists(file_path):
@@ -324,6 +609,9 @@ def load():
 
 
 def save():
+    if _services is None:
+        return
+
     file_path = os.path.join(settings.CONF_PATH, _STATE_FILE_NAME)
     
     logging.debug('saving upload services state to "%s"...' % file_path)
@@ -351,13 +639,17 @@ def save():
         file.close()
 
 
-def upload_media_file(camera_id, service_name, filename):
+def upload_media_file(camera_id, target_dir, service_name, filename):
+    # force a load from file with every upload,
+    # as settings might have changed
+    load()
+    
     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)
+        service.upload_file(target_dir, filename)
 
     except Exception as e:
         logging.error('failed to upload file "%s" with service %s: %s' % (filename, service, e))