skilleter-thingy 0.1.18__py3-none-any.whl → 0.1.21__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of skilleter-thingy might be problematic. Click here for more details.
- skilleter_thingy/ffind.py +2 -2
- skilleter_thingy/ggit.py +1 -1
- skilleter_thingy/ggrep.py +2 -1
- skilleter_thingy/git_br.py +3 -3
- skilleter_thingy/git_common.py +2 -2
- skilleter_thingy/git_hold.py +6 -6
- skilleter_thingy/git_parent.py +4 -3
- skilleter_thingy/git_review.py +16 -12
- skilleter_thingy/git_update.py +2 -2
- skilleter_thingy/git_wt.py +5 -5
- skilleter_thingy/gitcmp_helper.py +6 -1
- skilleter_thingy/gphotosync.py +12 -8
- skilleter_thingy/localphotosync.py +77 -358
- skilleter_thingy/multigit.py +30 -34
- skilleter_thingy/photodupe.py +10 -10
- skilleter_thingy/py_audit.py +4 -2
- skilleter_thingy/readable.py +13 -11
- skilleter_thingy/sysmon.py +2 -2
- skilleter_thingy/thingy/colour.py +6 -2
- skilleter_thingy/thingy/dircolors.py +20 -18
- skilleter_thingy/thingy/git2.py +0 -1
- skilleter_thingy/thingy/popup.py +1 -1
- skilleter_thingy/thingy/tfm_pane.py +3 -3
- skilleter_thingy/thingy/tidy.py +14 -13
- skilleter_thingy/trimpath.py +5 -4
- skilleter_thingy/venv_create.py +1 -1
- {skilleter_thingy-0.1.18.dist-info → skilleter_thingy-0.1.21.dist-info}/METADATA +1 -1
- skilleter_thingy-0.1.21.dist-info/PKG-INFO 2 +193 -0
- {skilleter_thingy-0.1.18.dist-info → skilleter_thingy-0.1.21.dist-info}/RECORD +33 -32
- {skilleter_thingy-0.1.18.dist-info → skilleter_thingy-0.1.21.dist-info}/WHEEL +0 -0
- {skilleter_thingy-0.1.18.dist-info → skilleter_thingy-0.1.21.dist-info}/entry_points.txt +0 -0
- {skilleter_thingy-0.1.18.dist-info → skilleter_thingy-0.1.21.dist-info}/licenses/LICENSE +0 -0
- {skilleter_thingy-0.1.18.dist-info → skilleter_thingy-0.1.21.dist-info}/top_level.txt +0 -0
|
@@ -4,24 +4,19 @@
|
|
|
4
4
|
Sync a directory tree full of photos into a tree organised by year, month and date
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
# TODO: Ignore patterns for source and destination file paths (.trashed* and .stversions)
|
|
8
|
+
# TODO: Use inotify to detect changes and run continuously
|
|
9
|
+
|
|
7
10
|
import os
|
|
11
|
+
import glob
|
|
12
|
+
import shutil
|
|
8
13
|
import sys
|
|
9
|
-
import datetime
|
|
10
14
|
import logging
|
|
11
15
|
import argparse
|
|
12
|
-
import glob
|
|
13
16
|
import re
|
|
14
|
-
import shutil
|
|
15
|
-
import PIL
|
|
16
|
-
import imagehash
|
|
17
17
|
|
|
18
|
-
from collections import defaultdict
|
|
19
18
|
from enum import Enum
|
|
20
19
|
|
|
21
|
-
from PIL import Image, ExifTags
|
|
22
|
-
|
|
23
|
-
import thingy.colour as colour
|
|
24
|
-
|
|
25
20
|
################################################################################
|
|
26
21
|
|
|
27
22
|
# Default locations for local storage of photos and videos
|
|
@@ -31,51 +26,25 @@ DEFAULT_VIDEO_DIR = os.path.expanduser('~/Videos')
|
|
|
31
26
|
|
|
32
27
|
# File extensions (case-insensitive)
|
|
33
28
|
|
|
34
|
-
IMAGE_EXTENSIONS = ('.jpg', '.jpeg', '.png'
|
|
35
|
-
VIDEO_EXTENSIONS = ('.mp4', '.mov'
|
|
36
|
-
IGNORE_EXTENSIONS = ('.ini', )
|
|
29
|
+
IMAGE_EXTENSIONS = ('.jpg', '.jpeg', '.png')
|
|
30
|
+
VIDEO_EXTENSIONS = ('.mp4', '.mov')
|
|
37
31
|
|
|
38
32
|
# Enum of filetypes
|
|
39
33
|
|
|
40
34
|
class FileType(Enum):
|
|
35
|
+
"""File types"""
|
|
41
36
|
IMAGE = 0
|
|
42
37
|
VIDEO = 1
|
|
43
38
|
UNKNOWN = 2
|
|
44
39
|
IGNORE = 3
|
|
45
40
|
|
|
46
|
-
# Regexes for matching date strings
|
|
47
|
-
|
|
48
|
-
YYYY_MM_DD_re = re.compile(r'^(\d{4}):(\d{2}):(\d{2})')
|
|
49
|
-
IMG_DATE_re = re.compile(r'(?:IMG|VID)[-_](\d{4})(\d{2})(\d{2})[-_.].*')
|
|
50
|
-
|
|
51
|
-
GENERAL_DATE_re = re.compile(r'(\d{4})[-_ ](\d{2})[-_ ](\d{2})')
|
|
52
|
-
|
|
53
|
-
YEAR_MONTH_PATH_re = re.compile(r'/(\d{4})/(\d{2})/')
|
|
54
|
-
|
|
55
|
-
YYYY_MM_re = re.compile(r'(\d{4})-(\d{2})')
|
|
56
|
-
|
|
57
|
-
DUP_RE = re.compile(r'(.*) \{aalq_f.*\}(.*)')
|
|
58
|
-
|
|
59
|
-
# Date format for YYYY-MM
|
|
60
|
-
|
|
61
|
-
DATE_FORMAT = '%Y-%m'
|
|
62
|
-
|
|
63
|
-
# If two pictures with the same name prefix have a hash differing by less than
|
|
64
|
-
# this then we don't hash the duplicates
|
|
65
|
-
|
|
66
|
-
MIN_HASH_DIFF = 15
|
|
67
|
-
|
|
68
41
|
################################################################################
|
|
69
42
|
|
|
70
|
-
def
|
|
71
|
-
"""
|
|
72
|
-
|
|
73
|
-
date_match = YYYY_MM_re.fullmatch(datestr)
|
|
43
|
+
def error(msg, status=1):
|
|
44
|
+
"""Exit with an error message"""
|
|
74
45
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
return datetime.date(int(date_match.group(1)), int(date_match.group(2)), day=1)
|
|
46
|
+
print(msg)
|
|
47
|
+
sys.exit(status)
|
|
79
48
|
|
|
80
49
|
################################################################################
|
|
81
50
|
|
|
@@ -84,20 +53,18 @@ def parse_command_line():
|
|
|
84
53
|
|
|
85
54
|
parser = argparse.ArgumentParser(description='Sync photos from Google Photos')
|
|
86
55
|
|
|
87
|
-
today = datetime.date.today()
|
|
88
|
-
|
|
89
56
|
parser.add_argument('--verbose', '-v', action='store_true', help='Output verbose status information')
|
|
90
57
|
parser.add_argument('--dryrun', '--dry-run', '-D', action='store_true', help='Just list files to be copied, without actually copying them')
|
|
91
|
-
parser.add_argument('--picturedir', '-P', action='store', default=DEFAULT_PHOTO_DIR,
|
|
92
|
-
|
|
93
|
-
parser.add_argument('--
|
|
58
|
+
parser.add_argument('--picturedir', '-P', action='store', default=DEFAULT_PHOTO_DIR,
|
|
59
|
+
help=f'Location of local picture storage directory (defaults to {DEFAULT_PHOTO_DIR})')
|
|
60
|
+
parser.add_argument('--videodir', '-V', action='store', default=DEFAULT_VIDEO_DIR,
|
|
61
|
+
help=f'Location of local video storage directory (defaults to {DEFAULT_VIDEO_DIR})')
|
|
94
62
|
parser.add_argument('--path', '-p', action='store', default=None, help='Path to sync from')
|
|
95
|
-
parser.add_argument('action', nargs='*', help='Actions to perform (report or sync)')
|
|
96
63
|
|
|
97
64
|
args = parser.parse_args()
|
|
98
65
|
|
|
99
66
|
if not args.path:
|
|
100
|
-
|
|
67
|
+
error('You must specify a source directory')
|
|
101
68
|
|
|
102
69
|
# Configure debugging
|
|
103
70
|
|
|
@@ -110,38 +77,10 @@ def parse_command_line():
|
|
|
110
77
|
logging.debug('Videos: %s', args.videodir)
|
|
111
78
|
logging.debug('Dry run: %d', args.dryrun)
|
|
112
79
|
|
|
113
|
-
args.local_dir = {'photo': args.picturedir, 'video': args.videodir}
|
|
114
|
-
|
|
115
80
|
return args
|
|
116
81
|
|
|
117
82
|
################################################################################
|
|
118
83
|
|
|
119
|
-
def get_exif_data(image):
|
|
120
|
-
"""Return EXIF data for the image as a dictionary"""
|
|
121
|
-
|
|
122
|
-
try:
|
|
123
|
-
img = Image.open(image)
|
|
124
|
-
|
|
125
|
-
img_exif = img.getexif()
|
|
126
|
-
except OSError as exc:
|
|
127
|
-
logging.info('Error reading EXIF data for %s - %s', image, exc)
|
|
128
|
-
img_exif = None
|
|
129
|
-
|
|
130
|
-
result = {}
|
|
131
|
-
|
|
132
|
-
if img_exif is None:
|
|
133
|
-
return result
|
|
134
|
-
|
|
135
|
-
for key, val in img_exif.items():
|
|
136
|
-
if key in ExifTags.TAGS:
|
|
137
|
-
result[ExifTags.TAGS[key]] = val
|
|
138
|
-
else:
|
|
139
|
-
result[key] = val
|
|
140
|
-
|
|
141
|
-
return result
|
|
142
|
-
|
|
143
|
-
################################################################################
|
|
144
|
-
|
|
145
84
|
def get_filetype(filename):
|
|
146
85
|
"""Return the type of a file"""
|
|
147
86
|
|
|
@@ -155,313 +94,93 @@ def get_filetype(filename):
|
|
|
155
94
|
if ext in VIDEO_EXTENSIONS:
|
|
156
95
|
return FileType.VIDEO
|
|
157
96
|
|
|
158
|
-
if ext in IGNORE_EXTENSIONS:
|
|
159
|
-
return FileType.IGNORE
|
|
160
|
-
|
|
161
97
|
return FileType.UNKNOWN
|
|
162
98
|
|
|
163
99
|
################################################################################
|
|
164
100
|
|
|
165
|
-
def
|
|
166
|
-
"""
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
image_list[filepath] = exif
|
|
192
|
-
except PIL.UnidentifiedImageError:
|
|
193
|
-
colour.write(f'[BOLD:WARNING:] Unable to get EXIF data from [BLUE:{filepath}]')
|
|
194
|
-
image_list[filepath] = {}
|
|
195
|
-
|
|
196
|
-
elif file_type == FileType.VIDEO:
|
|
197
|
-
# TODO: Is there a way of getting EXIF-type data from video files? (https://thepythoncode.com/article/extract-media-metadata-in-python but does it include date info?)
|
|
198
|
-
video_list[filepath] = {}
|
|
199
|
-
|
|
200
|
-
elif file_type == FileType.UNKNOWN:
|
|
201
|
-
unknown_list.append(filepath)
|
|
202
|
-
|
|
203
|
-
logging.info('Read %s image files', len(image_list))
|
|
204
|
-
logging.info('Read %s video files', len(video_list))
|
|
205
|
-
logging.info('Read %s unknown files', len(unknown_list))
|
|
206
|
-
|
|
207
|
-
return image_list, video_list, unknown_list
|
|
208
|
-
|
|
209
|
-
################################################################################
|
|
210
|
-
|
|
211
|
-
def get_media_date(name, info):
|
|
212
|
-
"""Try and determine the date for a given picture. Returns y, m, d or
|
|
213
|
-
None, None, None"""
|
|
214
|
-
|
|
215
|
-
# If the EXIF data has the date & time, just return that
|
|
216
|
-
|
|
217
|
-
if 'DateTimeOriginal' in info:
|
|
218
|
-
original_date_time = info['DateTimeOriginal']
|
|
219
|
-
|
|
220
|
-
date_match = YYYY_MM_DD_re.match(original_date_time)
|
|
221
|
-
if date_match:
|
|
222
|
-
year = date_match.group(1)
|
|
223
|
-
month = date_match.group(2)
|
|
224
|
-
day = date_match.group(3)
|
|
225
|
-
|
|
226
|
-
return year, month, day
|
|
227
|
-
|
|
228
|
-
# No EXIF date and time, try and parse it out of the filename
|
|
229
|
-
|
|
230
|
-
picture_name = os.path.basename(name)
|
|
231
|
-
|
|
232
|
-
date_match = IMG_DATE_re.match(picture_name) or GENERAL_DATE_re.search(picture_name)
|
|
233
|
-
|
|
234
|
-
if date_match:
|
|
235
|
-
year = date_match.group(1)
|
|
236
|
-
month = date_match.group(2)
|
|
237
|
-
day = date_match.group(3)
|
|
238
|
-
|
|
239
|
-
return year, month, day
|
|
240
|
-
|
|
241
|
-
date_match = YEAR_MONTH_PATH_re.search(name)
|
|
242
|
-
if date_match:
|
|
243
|
-
year = date_match.group(1)
|
|
244
|
-
month = date_match.group(2)
|
|
245
|
-
day = '00'
|
|
246
|
-
|
|
247
|
-
return year, month, day
|
|
248
|
-
|
|
249
|
-
# A miserable failure
|
|
250
|
-
|
|
251
|
-
return None, None, None
|
|
252
|
-
|
|
253
|
-
################################################################################
|
|
101
|
+
def media_sync(args):
|
|
102
|
+
"""Sync photos and videos from args.path to date-structured directory
|
|
103
|
+
trees in args.picturedir and args.videodir.
|
|
104
|
+
Assumes that the source files are in Android naming format:
|
|
105
|
+
(IMG|VID)_YYYYMMDD_*.(jpg|mp4)
|
|
106
|
+
Looks for a destination directory called:
|
|
107
|
+
YYYY/YYYY-MM-DD*/
|
|
108
|
+
If multiple destination directories exist, it uses the first one when the
|
|
109
|
+
names are sorted alphbetically
|
|
110
|
+
If a file with the same name exists in the destination directory it is
|
|
111
|
+
not overwritten"""
|
|
112
|
+
|
|
113
|
+
files_copied = 0
|
|
114
|
+
|
|
115
|
+
filetype_re = re.compile(r'(PANO|IMG|VID)[-_](\d{4})(\d{2})(\d{2})[-_.].*')
|
|
116
|
+
|
|
117
|
+
for sourcefile in [source for source in glob.glob(os.path.join(args.path, '*')) if os.path.isfile(source)]:
|
|
118
|
+
filetype = get_filetype(sourcefile)
|
|
119
|
+
|
|
120
|
+
if filetype == FileType.IMAGE:
|
|
121
|
+
dest_dir = args.picturedir
|
|
122
|
+
elif filetype == FileType.VIDEO:
|
|
123
|
+
dest_dir = args.videodir
|
|
124
|
+
else:
|
|
125
|
+
logging.info('Ignoring %s - unable to determine file type', sourcefile)
|
|
126
|
+
continue
|
|
254
127
|
|
|
255
|
-
|
|
256
|
-
|
|
128
|
+
date_match = filetype_re.fullmatch(os.path.basename(sourcefile))
|
|
129
|
+
if not date_match:
|
|
130
|
+
logging.debug('Ignoring %s - unable to extract date from filename', sourcefile)
|
|
131
|
+
continue
|
|
257
132
|
|
|
258
|
-
|
|
259
|
-
|
|
133
|
+
year = date_match.group(2)
|
|
134
|
+
month = date_match.group(3)
|
|
135
|
+
day = date_match.group(4)
|
|
260
136
|
|
|
261
|
-
|
|
262
|
-
|
|
137
|
+
default_dest_dir = f'{dest_dir}/{year}/{year}-{month}-{day}'
|
|
138
|
+
dest_dir_pattern = f'{default_dest_dir}*'
|
|
263
139
|
|
|
264
|
-
|
|
140
|
+
dest_dirs = [path for path in glob.glob(dest_dir_pattern) if os.path.isdir(path)]
|
|
265
141
|
|
|
266
|
-
|
|
267
|
-
day = None
|
|
142
|
+
sourcefile_name = os.path.basename(sourcefile)
|
|
268
143
|
|
|
269
|
-
|
|
270
|
-
destination_media_file_path = os.path.join(destination_dir, year, f'{year}-{month}-{day}', os.path.basename(media_file))
|
|
144
|
+
# Search any matching destination directories to see if the file exists
|
|
271
145
|
|
|
272
|
-
|
|
273
|
-
|
|
146
|
+
if dest_dirs:
|
|
147
|
+
for dest_dir in dest_dirs:
|
|
148
|
+
if os.path.isfile(os.path.join(dest_dir, sourcefile_name)):
|
|
149
|
+
break
|
|
274
150
|
else:
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
colour.write(f'Copying [BLUE:{media_file}] to [BLUE:{destination_dir_name}]')
|
|
278
|
-
|
|
279
|
-
if not dryrun:
|
|
280
|
-
os.makedirs(destination_dir_name, exist_ok=True)
|
|
281
|
-
|
|
282
|
-
shutil.copyfile(media_file, destination_media_file_path)
|
|
151
|
+
dest_dir = sorted(dest_dirs)[0]
|
|
283
152
|
else:
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
################################################################################
|
|
287
|
-
|
|
288
|
-
def local_directory(args, mediatype, year, month):
|
|
289
|
-
"""Return the location of the local picture directory for the specified year/month"""
|
|
290
|
-
|
|
291
|
-
return os.path.join(args.local_dir[mediatype], str(year), f'{year}-{month:02}')
|
|
292
|
-
|
|
293
|
-
################################################################################
|
|
294
|
-
|
|
295
|
-
def media_sync(dryrun, skip_no_day, media, media_files, local_dir):
|
|
296
|
-
"""Given a media type and list of local and remote files of the type, check
|
|
297
|
-
for out-of-sync files and sync any missing remote files to local storage"""
|
|
298
|
-
|
|
299
|
-
# Get the list of local and remote names of the specified media type
|
|
300
|
-
# TODO: Could be a problem if we have multiple files with the same name (e.g. in different months)
|
|
301
|
-
|
|
302
|
-
names = {'local': {}, 'remote': {}}
|
|
303
|
-
|
|
304
|
-
for name in media_files['local']:
|
|
305
|
-
names['local'][os.path.basename(name)] = name
|
|
306
|
-
|
|
307
|
-
for name in media_files['remote']:
|
|
308
|
-
names['remote'][os.path.basename(name)] = name
|
|
309
|
-
|
|
310
|
-
# Find matches and remove them
|
|
311
|
-
|
|
312
|
-
matching = 0
|
|
313
|
-
for name in names['local']:
|
|
314
|
-
if name in names['remote']:
|
|
315
|
-
matching += 1
|
|
316
|
-
|
|
317
|
-
del media_files['remote'][names['remote'][name]]
|
|
318
|
-
del media_files['local'][names['local'][name]]
|
|
319
|
-
|
|
320
|
-
if matching:
|
|
321
|
-
colour.write(f' [BOLD:{matching} {media} files are in sync]')
|
|
322
|
-
else:
|
|
323
|
-
colour.write(f' [BOLD:No {media} files are in sync]')
|
|
324
|
-
|
|
325
|
-
if media_files['local']:
|
|
326
|
-
colour.write(f' [BOLD:{len(media_files["local"])} local {media} files are out of sync]')
|
|
327
|
-
else:
|
|
328
|
-
colour.write(f' [BOLD:No local {media} files are out of sync]')
|
|
329
|
-
|
|
330
|
-
if media_files['remote']:
|
|
331
|
-
colour.write(f' [BOLD:{len(media_files["remote"])} remote {media} files are out of sync]')
|
|
332
|
-
sync_media_local(dryrun, skip_no_day, media_files['remote'], local_dir)
|
|
333
|
-
else:
|
|
334
|
-
colour.write(f' [BOLD:No remote {media} files are out of sync]')
|
|
335
|
-
|
|
336
|
-
colour.write('')
|
|
337
|
-
|
|
338
|
-
################################################################################
|
|
339
|
-
|
|
340
|
-
# TODO: Tidy this up!
|
|
341
|
-
def remove_duplicates(media_files):
|
|
342
|
-
"""Look for remote files which have an original and multiple
|
|
343
|
-
copies and remove the copies from the list of files to consider using the
|
|
344
|
-
imagehash library to detect duplicate or near-duplicate files.
|
|
345
|
-
"""
|
|
346
|
-
|
|
347
|
-
print('Checking for duplicate files')
|
|
348
|
-
|
|
349
|
-
# Originals can have upper or lower case extensions, copies only tend to have lower
|
|
350
|
-
# case, so build a lower case to original lookup table
|
|
351
|
-
|
|
352
|
-
names = {name.lower():name for name in media_files}
|
|
353
|
-
|
|
354
|
-
duplicates = defaultdict(list)
|
|
355
|
-
|
|
356
|
-
# Build a list of duplicates for each filename in the list - i.e. files with the same
|
|
357
|
-
# prefix and a suffix matching DUP_RE, indexed by the base filename (without the suffix)
|
|
358
|
-
|
|
359
|
-
for entry in names:
|
|
360
|
-
orig_match = DUP_RE.fullmatch(entry)
|
|
361
|
-
if orig_match:
|
|
362
|
-
original = orig_match.group(1) + orig_match.group(2)
|
|
363
|
-
|
|
364
|
-
duplicates[original].append(entry)
|
|
153
|
+
if not args.dryrun:
|
|
154
|
+
os.makedirs(default_dest_dir)
|
|
365
155
|
|
|
366
|
-
|
|
367
|
-
# to build a list of actual duplicates (or at least nearly-indistinguishable images)
|
|
368
|
-
# TODO: Better to build list of all hashes, then find near-duplicates
|
|
156
|
+
dest_dir = default_dest_dir
|
|
369
157
|
|
|
370
|
-
|
|
371
|
-
for entry, dupes in duplicates.items():
|
|
372
|
-
# If the base file (no suffix) exists use that as the base, otherwise
|
|
373
|
-
# use the first duplicate (we can have a situation where we have duplicates
|
|
374
|
-
# and no original).
|
|
158
|
+
dest_file = os.path.join(dest_dir, sourcefile_name)
|
|
375
159
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
if entry in names:
|
|
381
|
-
try:
|
|
382
|
-
base_hash = str(imagehash.average_hash(Image.open(names[entry])))
|
|
383
|
-
|
|
384
|
-
hash_list[base_hash].append(names[entry])
|
|
385
|
-
except OSError:
|
|
386
|
-
pass
|
|
387
|
-
|
|
388
|
-
# Calculate the hash of each of the potential duplicates and if they
|
|
389
|
-
# are close enough to the base hash, then add them to the real duplicate list
|
|
390
|
-
|
|
391
|
-
for entry in dupes:
|
|
392
|
-
filename = names[entry]
|
|
393
|
-
try:
|
|
394
|
-
dupe_hash = str(imagehash.average_hash(Image.open(filename)))
|
|
395
|
-
|
|
396
|
-
hash_list[dupe_hash].append(filename)
|
|
397
|
-
except OSError:
|
|
398
|
-
colour.write(f'[BOLD:WARNING]: Unable to read {filename}')
|
|
399
|
-
|
|
400
|
-
# Remove entries with identical hash values
|
|
401
|
-
|
|
402
|
-
for dupes in hash_list:
|
|
403
|
-
for dupe in hash_list[dupes][1:]:
|
|
404
|
-
actual_duplicates.add(dupe)
|
|
405
|
-
hash_list[dupes] = hash_list[dupes][0]
|
|
406
|
-
|
|
407
|
-
# Look for adjaced entries in the sorted list of hash values that differ by less then the minimum
|
|
408
|
-
# and remove the duplicates
|
|
409
|
-
|
|
410
|
-
hash_values = sorted(hash_list.keys())
|
|
411
|
-
logging.debug('Hash values for duplicates: %s', hash_values)
|
|
412
|
-
|
|
413
|
-
for i in range(len(hash_values)-1):
|
|
414
|
-
if int(hash_values[i+1], 16) - int(hash_values[i], 16) < MIN_HASH_DIFF:
|
|
415
|
-
actual_duplicates.add(hash_list[hash_values[i+1]])
|
|
416
|
-
|
|
417
|
-
# Remove all the entries in the real duplicates list
|
|
418
|
-
|
|
419
|
-
for entry in actual_duplicates:
|
|
420
|
-
logging.info('Removing %s as a (near-)duplicate', os.path.basename(entry))
|
|
421
|
-
del media_files[entry]
|
|
422
|
-
|
|
423
|
-
################################################################################
|
|
424
|
-
|
|
425
|
-
def photo_sync(args):
|
|
426
|
-
"""Synchronise the photos"""
|
|
427
|
-
|
|
428
|
-
colour.write('[GREEN:%s]' % '-'*80)
|
|
429
|
-
|
|
430
|
-
# Read the pictures and their EXIF data to get the dates
|
|
431
|
-
|
|
432
|
-
media_files = {'photo': {}, 'video': {}}
|
|
433
|
-
unknown_files = {}
|
|
434
|
-
|
|
435
|
-
media_files['photo']['remote'], media_files['video']['remote'], unknown_files['remote'] = find_files([args.path])
|
|
436
|
-
media_files['photo']['local'], media_files['video']['local'], unknown_files['local'] = find_files([args.picturedir, args.videodir])
|
|
437
|
-
|
|
438
|
-
for media in ('photo', 'video'):
|
|
439
|
-
remove_duplicates(media_files[media]['remote'])
|
|
440
|
-
|
|
441
|
-
colour.write('[GREEN:%s]' % '-'*80)
|
|
442
|
-
|
|
443
|
-
media_sync(args.dryrun, args.skip_no_day, media, media_files['photo'], args.picturedir)
|
|
444
|
-
media_sync(args.dryrun, args.skip_no_day, media, media_files['video'], args.videodir)
|
|
445
|
-
|
|
446
|
-
################################################################################
|
|
447
|
-
|
|
448
|
-
def main():
|
|
449
|
-
"""Entry point"""
|
|
160
|
+
if os.path.exists(dest_file):
|
|
161
|
+
logging.debug('Destination file %s already exists', dest_file)
|
|
162
|
+
else:
|
|
163
|
+
logging.info('Copying %s to %s', sourcefile, dest_file)
|
|
450
164
|
|
|
451
|
-
|
|
165
|
+
if not args.dryrun:
|
|
166
|
+
shutil.copyfile(sourcefile, dest_file)
|
|
452
167
|
|
|
453
|
-
|
|
168
|
+
files_copied += 1
|
|
454
169
|
|
|
455
|
-
|
|
170
|
+
print(f'{files_copied} files copied')
|
|
456
171
|
|
|
457
172
|
################################################################################
|
|
458
173
|
|
|
459
174
|
def localphotosync():
|
|
460
175
|
"""Entry point"""
|
|
461
176
|
try:
|
|
462
|
-
|
|
177
|
+
args = parse_command_line()
|
|
178
|
+
|
|
179
|
+
media_sync(args)
|
|
180
|
+
|
|
463
181
|
except KeyboardInterrupt:
|
|
464
182
|
sys.exit(1)
|
|
183
|
+
|
|
465
184
|
except BrokenPipeError:
|
|
466
185
|
sys.exit(2)
|
|
467
186
|
|