7
7
import dropbox
8
8
9
9
from dsmr_backup .models .settings import DropboxSettings
10
+ from dsmr_dropbox .dropboxinc .dropbox_content_hasher import DropboxContentHasher
10
11
from dsmr_frontend .models .message import Notification
11
12
import dsmr_backup .services .backup
12
13
@@ -26,11 +27,14 @@ def sync():
26
27
27
28
backup_directory = dsmr_backup .services .backup .get_backup_directory ()
28
29
29
- # Just check for modified files since the last sync .
30
- for (_ , _ , filenames ) in os .walk (backup_directory ):
30
+ # Sync each file, recursively .
31
+ for (root , dirs , filenames ) in os .walk (backup_directory ):
31
32
for current_file in filenames :
32
- current_file_path = os .path .join (backup_directory , current_file )
33
- check_synced_file (file_path = current_file_path , dropbox_settings = dropbox_settings )
33
+ sync_file (
34
+ dropbox_settings = dropbox_settings ,
35
+ local_root_dir = backup_directory ,
36
+ abs_file_path = os .path .abspath (os .path .join (root , current_file ))
37
+ )
34
38
35
39
# Try again in a while.
36
40
DropboxSettings .objects .update (
@@ -41,24 +45,37 @@ def sync():
41
45
)
42
46
43
47
44
- def check_synced_file (file_path , dropbox_settings ):
45
- file_stats = os .stat (file_path )
48
+ def sync_file (dropbox_settings , local_root_dir , abs_file_path ):
49
+ # The path we use in our Dropbox app folder.
50
+ relative_file_path = abs_file_path .replace (local_root_dir , '' )
46
51
47
52
# Ignore empty files.
48
- if file_stats .st_size == 0 :
53
+ if os . stat ( abs_file_path ) .st_size == 0 :
49
54
return
50
55
51
- last_modified = timezone .datetime .fromtimestamp (file_stats .st_mtime )
52
- last_modified = timezone .make_aware (last_modified )
53
- last_modified = timezone .localtime (last_modified )
54
- latest_sync = dropbox_settings .latest_sync
56
+ # Check whether the file is already at Dropbox, if so, check its hash.
57
+ dbx = dropbox .Dropbox (dropbox_settings .access_token )
55
58
56
- # Ignore when file was not altered since last sync.
57
- if latest_sync and last_modified < timezone .localtime (latest_sync ):
58
- return
59
+ try :
60
+ dropbox_meta = dbx .files_get_metadata (relative_file_path )
61
+ except dropbox .exceptions .ApiError as exception :
62
+ error_message = str (exception .error )
63
+ dropbox_meta = None
64
+
65
+ # Unexpected.
66
+ if 'not_found' not in error_message :
67
+ return logger .error (' - Dropbox error: %s' , error_message )
68
+
69
+ # Calculate local hash and compare with remote. Ignore if the remote file is exactly the same.
70
+ if dropbox_meta and calculate_content_hash (abs_file_path ) == dropbox_meta .content_hash :
71
+ return logger .debug (' - Dropbox content hash is the same, skipping: %s' , relative_file_path )
59
72
60
73
try :
61
- upload_chunked (file_path = file_path )
74
+ upload_chunked (
75
+ dropbox_settings = dropbox_settings ,
76
+ local_file_path = abs_file_path ,
77
+ remote_file_path = relative_file_path
78
+ )
62
79
except dropbox .exceptions .DropboxException as exception :
63
80
error_message = str (exception .error )
64
81
logger .error (' - Dropbox error: %s' , error_message )
@@ -94,28 +111,23 @@ def check_synced_file(file_path, dropbox_settings):
94
111
raise
95
112
96
113
97
- def upload_chunked (file_path ):
114
+ def upload_chunked (dropbox_settings , local_file_path , remote_file_path ):
98
115
""" Uploads a file in chucks to Dropbox, allowing it to resume on (connection) failure. """
99
- # For backend logging in Supervisor.
100
- logger .info (' - Uploading file to Dropbox: %s' , file_path )
101
-
102
- dropbox_settings = DropboxSettings .get_solo ()
103
- file_name = os .path .split (file_path )[- 1 ]
104
- dest_path = '/{}' .format (file_name ) # The slash indicates it's relative to the root of app folder.
116
+ logger .info (' - Syncing file with Dropbox: %s' , remote_file_path )
105
117
106
118
dbx = dropbox .Dropbox (dropbox_settings .access_token )
107
119
write_mode = dropbox .files .WriteMode .overwrite
108
120
109
- file_handle = open (file_path , 'rb' )
110
- file_size = os .path .getsize (file_path )
121
+ file_handle = open (local_file_path , 'rb' )
122
+ file_size = os .path .getsize (local_file_path )
111
123
112
124
# Many thanks to https://stackoverflow.com/documentation/dropbox-api/409/uploading-a-file/1927/uploading-a-file-usin
113
125
# g-the-dropbox-python-sdk#t=201610181733061624381
114
126
CHUNK_SIZE = 2 * 1024 * 1024
115
127
116
128
# Small uploads should be transfers at one go.
117
129
if file_size <= CHUNK_SIZE :
118
- dbx .files_upload (file_handle .read (), dest_path , mode = write_mode )
130
+ dbx .files_upload (file_handle .read (), remote_file_path , mode = write_mode )
119
131
120
132
# Large uploads can be sent in chunks, by creating a session allowing multiple separate uploads.
121
133
else :
@@ -125,7 +137,7 @@ def upload_chunked(file_path):
125
137
session_id = upload_session_start_result .session_id ,
126
138
offset = file_handle .tell ()
127
139
)
128
- commit = dropbox .files .CommitInfo (path = dest_path , mode = write_mode )
140
+ commit = dropbox .files .CommitInfo (path = remote_file_path , mode = write_mode )
129
141
130
142
# We keep sending the data in chunks, until we reach the last one, then we instruct Dropbox to finish the upload
131
143
# by combining all the chunks sent previously.
@@ -137,3 +149,16 @@ def upload_chunked(file_path):
137
149
cursor .offset = file_handle .tell ()
138
150
139
151
file_handle .close ()
152
+
153
+
154
+ def calculate_content_hash (file_path ):
155
+ """ Calculates the Dropbox hash of a file: https://www.dropbox.com/developers/reference/content-hash """
156
+ hasher = DropboxContentHasher ()
157
+ with open (file_path , 'rb' ) as f :
158
+ while True :
159
+ chunk = f .read (DropboxContentHasher .BLOCK_SIZE )
160
+ if len (chunk ) == 0 :
161
+ break
162
+ hasher .update (chunk )
163
+
164
+ return hasher .hexdigest ()
0 commit comments