skilleter-thingy 0.0.39__py3-none-any.whl → 0.0.41__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.

Files changed (68) hide show
  1. skilleter_thingy/__init__.py +6 -0
  2. skilleter_thingy/addpath.py +107 -0
  3. skilleter_thingy/borger.py +269 -0
  4. skilleter_thingy/console_colours.py +63 -0
  5. skilleter_thingy/diskspacecheck.py +67 -0
  6. skilleter_thingy/docker_purge.py +113 -0
  7. skilleter_thingy/ffind.py +536 -0
  8. skilleter_thingy/ggit.py +90 -0
  9. skilleter_thingy/ggrep.py +154 -0
  10. skilleter_thingy/git_br.py +180 -0
  11. skilleter_thingy/git_ca.py +142 -0
  12. skilleter_thingy/git_cleanup.py +287 -0
  13. skilleter_thingy/git_co.py +220 -0
  14. skilleter_thingy/git_common.py +61 -0
  15. skilleter_thingy/git_hold.py +154 -0
  16. skilleter_thingy/git_mr.py +92 -0
  17. skilleter_thingy/git_parent.py +77 -0
  18. skilleter_thingy/git_review.py +1428 -0
  19. skilleter_thingy/git_update.py +385 -0
  20. skilleter_thingy/git_wt.py +96 -0
  21. skilleter_thingy/gitcmp_helper.py +322 -0
  22. skilleter_thingy/gitprompt.py +274 -0
  23. skilleter_thingy/gl.py +174 -0
  24. skilleter_thingy/gphotosync.py +610 -0
  25. skilleter_thingy/linecount.py +155 -0
  26. skilleter_thingy/moviemover.py +133 -0
  27. skilleter_thingy/photodupe.py +136 -0
  28. skilleter_thingy/phototidier.py +248 -0
  29. skilleter_thingy/py_audit.py +131 -0
  30. skilleter_thingy/readable.py +270 -0
  31. skilleter_thingy/remdir.py +126 -0
  32. skilleter_thingy/rmdupe.py +550 -0
  33. skilleter_thingy/rpylint.py +91 -0
  34. skilleter_thingy/splitpics.py +99 -0
  35. skilleter_thingy/strreplace.py +82 -0
  36. skilleter_thingy/sysmon.py +435 -0
  37. skilleter_thingy/tfm.py +920 -0
  38. skilleter_thingy/tfparse.py +101 -0
  39. skilleter_thingy/thingy/__init__.py +6 -0
  40. skilleter_thingy/thingy/colour.py +213 -0
  41. skilleter_thingy/thingy/dc_curses.py +278 -0
  42. skilleter_thingy/thingy/dc_defaults.py +221 -0
  43. skilleter_thingy/thingy/dc_util.py +50 -0
  44. skilleter_thingy/thingy/dircolors.py +308 -0
  45. skilleter_thingy/thingy/docker.py +95 -0
  46. skilleter_thingy/thingy/files.py +142 -0
  47. skilleter_thingy/thingy/git.py +1371 -0
  48. skilleter_thingy/thingy/git2.py +1307 -0
  49. skilleter_thingy/thingy/gitlab.py +193 -0
  50. skilleter_thingy/thingy/logger.py +112 -0
  51. skilleter_thingy/thingy/path.py +156 -0
  52. skilleter_thingy/thingy/popup.py +87 -0
  53. skilleter_thingy/thingy/process.py +112 -0
  54. skilleter_thingy/thingy/run.py +334 -0
  55. skilleter_thingy/thingy/tfm_pane.py +595 -0
  56. skilleter_thingy/thingy/tidy.py +160 -0
  57. skilleter_thingy/trimpath.py +84 -0
  58. skilleter_thingy/window_rename.py +92 -0
  59. skilleter_thingy/xchmod.py +125 -0
  60. skilleter_thingy/yamlcheck.py +89 -0
  61. {skilleter_thingy-0.0.39.dist-info → skilleter_thingy-0.0.41.dist-info}/METADATA +1 -1
  62. skilleter_thingy-0.0.41.dist-info/RECORD +66 -0
  63. skilleter_thingy-0.0.41.dist-info/top_level.txt +1 -0
  64. skilleter_thingy-0.0.39.dist-info/RECORD +0 -6
  65. skilleter_thingy-0.0.39.dist-info/top_level.txt +0 -1
  66. {skilleter_thingy-0.0.39.dist-info → skilleter_thingy-0.0.41.dist-info}/LICENSE +0 -0
  67. {skilleter_thingy-0.0.39.dist-info → skilleter_thingy-0.0.41.dist-info}/WHEEL +0 -0
  68. {skilleter_thingy-0.0.39.dist-info → skilleter_thingy-0.0.41.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,610 @@
1
+ #!/usr/bin/env python3
2
+
3
+ """
4
+ Sync Google photos with a local directory
5
+
6
+ TODO: Sync local -> remote (leave this to Google Drive app?)
7
+ TODO: Tidy cache (either automatic or command line option) - just remove anything with a date N months before start month
8
+ TODO: When checking photos are present both locally and remotely don't just check filename (what do we check and would it help?)
9
+ TODO: Investigate access to remote photos by day - is it really too slow for practical use as the rclone web site says? - ANS: Looks feasible, but have to sync each day separately, doable though would be problems with local directories with suffixes after date - probably not worth it as month works OK.
10
+ """
11
+
12
+ import os
13
+ import sys
14
+ import datetime
15
+ import logging
16
+ import argparse
17
+ import subprocess
18
+ import glob
19
+ import re
20
+ import shutil
21
+ import PIL
22
+ import imagehash
23
+
24
+ from collections import defaultdict
25
+
26
+ from dateutil.relativedelta import relativedelta
27
+ from PIL import Image, ExifTags
28
+
29
+ import thingy.colour as colour
30
+
31
+ ################################################################################
32
+
33
+ # Default locations for local storage of photos and videos
34
+
35
+ DEFAULT_PHOTO_DIR = os.path.expanduser('~/Pictures')
36
+ DEFAULT_VIDEO_DIR = os.path.expanduser('~/Videos')
37
+
38
+ # Default remote name to use with rclone
39
+
40
+ DEFAULT_RCLONE_REMOTE = 'GooglePhotos'
41
+
42
+ # File extensions
43
+
44
+ IMAGE_EXTENSIONS = ('.jpg', '.jpeg', '.rw2', '.png', )
45
+ VIDEO_EXTENSIONS = ('.mp4', '.mov', )
46
+ IGNORE_EXTENSIONS = ('.ini', )
47
+
48
+ # Default number of months to sync
49
+
50
+ DEFAULT_MONTHS = 2
51
+
52
+ # Default number of months to keep in cache prior to current start date
53
+
54
+ DEFAULT_KEEP = 1
55
+
56
+ # Default cache location
57
+
58
+ DEFAULT_CACHE_DIR = os.path.expanduser('~/.cache/gphotosync')
59
+
60
+ # Enum of filetypes
61
+
62
+ FILETYPE_IMAGE = 0
63
+ FILETYPE_VIDEO = 1
64
+ FILETYPE_UNKNOWN = 2
65
+ FILETYPE_IGNORE = 3
66
+
67
+ # Regexes for matching date strings
68
+
69
+ YYYY_MM_DD_re = re.compile(r'^(\d{4}):(\d{2}):(\d{2})')
70
+ IMG_DATE_re = re.compile(r'(?:IMG|VID)[-_](\d{4})(\d{2})(\d{2})[-_.].*')
71
+
72
+ GENERAL_DATE_re = re.compile(r'(\d{4})[-_ ](\d{2})[-_ ](\d{2})')
73
+
74
+ YEAR_MONTH_PATH_re = re.compile(r'/(\d{4})/(\d{2})/')
75
+
76
+ YYYY_MM_re = re.compile(r'(\d{4})-(\d{2})')
77
+
78
+ DUP_RE = re.compile(r'(.*) \{aalq_f.*\}(.*)')
79
+
80
+ # Date format for YYYY-MM
81
+
82
+ DATE_FORMAT = '%Y-%m'
83
+
84
+ # If two pictures with the same name prefix have a hash differing by less than
85
+ # this then we don't hash the duplicates
86
+
87
+ MIN_HASH_DIFF = 15
88
+
89
+ ################################################################################
90
+
91
+ def parse_yyyymm(datestr):
92
+ """Convert a date string in the form YYYY-MM to a datetime.date"""
93
+
94
+ date_match = YYYY_MM_re.fullmatch(datestr)
95
+
96
+ if not date_match:
97
+ colour.error(f'ERROR: Invalid date: {datestr}')
98
+
99
+ return datetime.date(int(date_match.group(1)), int(date_match.group(2)), day=1)
100
+
101
+ ################################################################################
102
+
103
+ def parse_command_line():
104
+ """Parse and validate the command line options"""
105
+
106
+ parser = argparse.ArgumentParser(description='Sync photos from Google Photos')
107
+
108
+ today = datetime.date.today()
109
+
110
+ default_end_date = datetime.date(today.year, today.month, 1)
111
+
112
+ parser.add_argument('--verbose', '-v', action='store_true', help='Output verbose status information')
113
+ parser.add_argument('--dryrun', '-D', action='store_true', help='Just list files to be copied, without actually copying them')
114
+ parser.add_argument('--picturedir', '-P', action='store', default=DEFAULT_PHOTO_DIR, help=f'Location of local picture storage directory (defaults to {DEFAULT_PHOTO_DIR})')
115
+ parser.add_argument('--videodir', '-V', action='store', default=DEFAULT_VIDEO_DIR, help=f'Location of local video storage directory (defaults to {DEFAULT_VIDEO_DIR})')
116
+ parser.add_argument('--start', '-s', action='store', default=None, help='Start date (in the form YYYY-MM, defaults to current month)')
117
+ parser.add_argument('--end', '-e', action='store', default=None, help=f'End date (in the form YYYY-MM, defaults to {DEFAULT_MONTHS} before the start date)')
118
+ parser.add_argument('--months', '-m', action='store', type=int, default=None, help='Synchronise this number of months of data (current month included)')
119
+ parser.add_argument('--cache', '-c', action='store', default=DEFAULT_CACHE_DIR, help=f'Cache directory for Google photos (defaults to {DEFAULT_CACHE_DIR})')
120
+ parser.add_argument('--rclone', '-r', action='store', default=DEFAULT_RCLONE_REMOTE, help=f'rclone remote name for Google photos (defaults to {DEFAULT_RCLONE_REMOTE})')
121
+ parser.add_argument('--no-update', '-N', action='store_true', help='Do not update local cache')
122
+ parser.add_argument('--keep', '-k', action='store', type=int, default=DEFAULT_KEEP, help=f'Keep this number of months before the start date in the cache (defaults to {DEFAULT_KEEP})')
123
+ parser.add_argument('action', nargs='*', help='Actions to perform (report or sync)')
124
+
125
+ args = parser.parse_args()
126
+
127
+ # Set the start and end date based on parameters and defaults.
128
+ # Use can specify between zero and up to 2 of start date, end date, and number of months
129
+
130
+ if args.months and args.start and args.end:
131
+ colour.error('You cannot specify a number of months and a start date AND an end date')
132
+
133
+ # If nothing specified, then we sync the default number of months ending at this month
134
+
135
+ if not args.start and not args.end and not args.months:
136
+ args.months = DEFAULT_MONTHS
137
+
138
+ if args.start:
139
+ # If the start date has been specified then use the specified end data, or end date + months
140
+
141
+ args.start = parse_yyyymm(args.start)
142
+
143
+ if args.end:
144
+ args.end = parse_yyyymm(args.end)
145
+ else:
146
+ args.end = args.start + relativedelta(months=args.months-1)
147
+
148
+ else:
149
+ # Otherwise, use the end date if specified and calculate the start date as being end_date - months
150
+
151
+ if args.end:
152
+ args.end = parse_yyyymm(args.end)
153
+ else:
154
+ args.end = default_end_date
155
+
156
+ args.start = args.end - relativedelta(months=args.months-1)
157
+
158
+ # Sanity check
159
+
160
+ if args.end > default_end_date:
161
+ colour.error(f'End date for synchronisation is in the future ({args.end})')
162
+
163
+ # Configure debugging
164
+
165
+ logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO)
166
+
167
+ # Report parameters if verbose
168
+
169
+ logging.debug('Start: %s', args.start)
170
+ logging.debug('End: %s', args.end)
171
+ logging.debug('Months: %d', args.months)
172
+ logging.debug('Pictures: %s', args.picturedir)
173
+ logging.debug('Videos: %s', args.videodir)
174
+ logging.debug('Cache: %s', args.cache)
175
+ logging.debug('Keep: %d', args.keep)
176
+ logging.debug('rclone: %s', args.rclone)
177
+ logging.debug('No update: %d', args.no_update)
178
+ logging.debug('Dry run: %d', args.dryrun)
179
+
180
+ args.local_dir = {'photo': args.picturedir, 'video': args.videodir}
181
+
182
+ return args
183
+
184
+ ################################################################################
185
+
186
+ def get_exif_data(image):
187
+ """Return EXIF data for the image as a dictionary"""
188
+
189
+ try:
190
+ img = Image.open(image)
191
+
192
+ img_exif = img.getexif()
193
+ except OSError as exc:
194
+ logging.info('Error reading EXIF data for %s - %s', image, exc)
195
+ img_exif = None
196
+
197
+ result = {}
198
+
199
+ if img_exif is None:
200
+ return result
201
+
202
+ for key, val in img_exif.items():
203
+ if key in ExifTags.TAGS:
204
+ result[ExifTags.TAGS[key]] = val
205
+ else:
206
+ result[key] = val
207
+
208
+ return result
209
+
210
+ ################################################################################
211
+
212
+ def get_filetype(filename):
213
+ """Return the type of a file"""
214
+
215
+ _, ext = os.path.splitext(filename)
216
+
217
+ ext = ext.lower()
218
+
219
+ if ext in IMAGE_EXTENSIONS:
220
+ return FILETYPE_IMAGE
221
+
222
+ if ext in VIDEO_EXTENSIONS:
223
+ return FILETYPE_VIDEO
224
+
225
+ if ext in IGNORE_EXTENSIONS:
226
+ return FILETYPE_IGNORE
227
+
228
+ return FILETYPE_UNKNOWN
229
+
230
+ ################################################################################
231
+
232
+ def find_files(directory_wildcards):
233
+ """Return a list of all the files in the specified directory tree, which can contain wildcards,
234
+ as 3 lists; pictures, videos and unknown."""
235
+
236
+ image_list = {}
237
+ video_list = {}
238
+ unknown_list = []
239
+
240
+ logging.info('Reading files in the directory tree(s) at %s', ', '.join(directory_wildcards))
241
+
242
+ for directory_wildcard in directory_wildcards:
243
+ directories = glob.glob(directory_wildcard)
244
+
245
+ for directory in directories:
246
+ for root, _, files in os.walk(directory):
247
+ logging.debug('Reading %s', root)
248
+
249
+ for file in files:
250
+ filepath = os.path.join(root, file)
251
+
252
+ file_type = get_filetype(filepath)
253
+
254
+ if file_type == FILETYPE_IMAGE:
255
+ try:
256
+ exif = get_exif_data(filepath)
257
+
258
+ image_list[filepath] = exif
259
+ except PIL.UnidentifiedImageError:
260
+ colour.write(f'[BOLD:WARNING:] Unable to get EXIF data from [BLUE:{filepath}]')
261
+ image_list[filepath] = {}
262
+
263
+ elif file_type == FILETYPE_VIDEO:
264
+ # 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?)
265
+ video_list[filepath] = {}
266
+
267
+ elif file_type == FILETYPE_UNKNOWN:
268
+ unknown_list.append(filepath)
269
+
270
+ logging.info('Read %s image files', len(image_list))
271
+ logging.info('Read %s video files', len(video_list))
272
+ logging.info('Read %s unknown files', len(unknown_list))
273
+
274
+ return image_list, video_list, unknown_list
275
+
276
+ ################################################################################
277
+
278
+ def get_media_date(name, info):
279
+ """Try and determine the date for a given picture. Returns y, m, d or
280
+ None, None, None"""
281
+
282
+ # If the EXIF data has the date & time, just return that
283
+
284
+ if 'DateTimeOriginal' in info:
285
+ original_date_time = info['DateTimeOriginal']
286
+
287
+ date_match = YYYY_MM_DD_re.match(original_date_time)
288
+ if date_match:
289
+ year = date_match.group(1)
290
+ month = date_match.group(2)
291
+ day = date_match.group(3)
292
+
293
+ return year, month, day
294
+
295
+ # No EXIF date and time, try and parse it out of the filename
296
+
297
+ picture_name = os.path.basename(name)
298
+
299
+ date_match = IMG_DATE_re.match(picture_name) or GENERAL_DATE_re.search(picture_name)
300
+
301
+ if date_match:
302
+ year = date_match.group(1)
303
+ month = date_match.group(2)
304
+ day = date_match.group(3)
305
+
306
+ return year, month, day
307
+
308
+ date_match = YEAR_MONTH_PATH_re.search(name)
309
+ if date_match:
310
+ year = date_match.group(1)
311
+ month = date_match.group(2)
312
+ day = '00'
313
+
314
+ return year, month, day
315
+
316
+ # A miserable failure
317
+
318
+ return None, None, None
319
+
320
+ ################################################################################
321
+
322
+ def sync_media_local(dryrun, media_files, destination_dir):
323
+ """Sync files from the cache to local storage"""
324
+
325
+ # Iterate through the list of remote media_files to try work out the date and
326
+ # time so that we can copy it the correct local location
327
+
328
+ for media_file in media_files:
329
+ year, month, day = get_media_date(media_file, media_files[media_file])
330
+
331
+ if year and month and day:
332
+ destination_media_file_path = os.path.join(destination_dir, year, f'{year}-{month}-{day}', os.path.basename(media_file))
333
+
334
+ if os.path.exists(destination_media_file_path):
335
+ colour.write(f'[RED:WARNING]: Destination [BLUE:{destination_media_file_path}] already exists - file will not be overwritten!')
336
+ else:
337
+ destination_dir_name = os.path.dirname(destination_media_file_path)
338
+
339
+ colour.write(f'Copying [BLUE:{media_file}] to [BLUE:{destination_dir_name}]')
340
+
341
+ if not dryrun:
342
+ os.makedirs(destination_dir_name, exist_ok=True)
343
+
344
+ shutil.copyfile(media_file, destination_media_file_path)
345
+ else:
346
+ colour.write(f'[RED:ERROR]: Unable to determine where to copy [BLUE:{media_file}]')
347
+
348
+ ################################################################################
349
+
350
+ def cache_directory(args, year, month):
351
+ """Return the location of the cache directory for the specified year/month"""
352
+
353
+ return os.path.join(args.cache, str(year), f'{month:02}')
354
+
355
+ ################################################################################
356
+
357
+ def local_directory(args, mediatype, year, month):
358
+ """Return the location of the local picture directory for the specified year/month"""
359
+
360
+ return os.path.join(args.local_dir[mediatype], str(year), f'{year}-{month:02}')
361
+
362
+ ###############################################################################
363
+
364
+ def update_cache(args, year, month):
365
+ """Update the local cache for the specified year and month"""
366
+
367
+ cache_dir = cache_directory(args, year, month)
368
+
369
+ os.makedirs(cache_dir, exist_ok=True)
370
+
371
+ # Sync Google photos for the specified year and month into it
372
+
373
+ if not args.no_update:
374
+ cmd = ['rclone', 'sync', '--progress', f'{args.rclone}:media/by-month/{year}/{year}-{month:02}/', cache_dir]
375
+
376
+ colour.write('[GREEN:%s]' % '-'*80)
377
+ colour.write(f'[BOLD:Caching photos for] [BLUE:{month:02}/{year}]')
378
+
379
+ try:
380
+ logging.info('Running %s', ' '.join(cmd))
381
+
382
+ subprocess.run(cmd, check=True)
383
+ except subprocess.CalledProcessError:
384
+ colour.error(f'[RED:ERROR]: Failed to sync Google photos for month [BLUE:{month}] of year [BLUE:{year}]')
385
+
386
+ ################################################################################
387
+
388
+ def media_sync(dryrun, media, media_files, local_dir):
389
+ """Given a media type and list of local and remote files of the type, check
390
+ for out-of-sync files and sync any missing remote files to local storage"""
391
+
392
+ # Get the list of local and remote names of the specified media type
393
+ # TODO: Could be a problem if we have multiple files with the same name (e.g. in different months)
394
+
395
+ names = {'local': {}, 'remote': {}}
396
+
397
+ for name in media_files['local']:
398
+ names['local'][os.path.basename(name)] = name
399
+
400
+ for name in media_files['remote']:
401
+ names['remote'][os.path.basename(name)] = name
402
+
403
+ # Find matches and remove them
404
+
405
+ matching = 0
406
+ for name in names['local']:
407
+ if name in names['remote']:
408
+ matching += 1
409
+
410
+ del media_files['remote'][names['remote'][name]]
411
+ del media_files['local'][names['local'][name]]
412
+
413
+ if matching:
414
+ colour.write(f' [BOLD:{matching} {media} files are in sync]')
415
+ else:
416
+ colour.write(f' [BOLD:No {media} files are in sync]')
417
+
418
+ if media_files['local']:
419
+ colour.write(f' [BOLD:{len(media_files["local"])} local {media} files are out of sync]')
420
+ else:
421
+ colour.write(f' [BOLD:No local {media} files are out of sync]')
422
+
423
+ if media_files['remote']:
424
+ colour.write(f' [BOLD:{len(media_files["remote"])} remote {media} files are out of sync]')
425
+ sync_media_local(dryrun, media_files['remote'], local_dir)
426
+ else:
427
+ colour.write(f' [BOLD:No remote {media} files are out of sync]')
428
+
429
+ colour.write('')
430
+
431
+ ################################################################################
432
+
433
+ # TODO: Tidy this up!
434
+ def remove_duplicates(media_files):
435
+ """Look for remote files which have an original and multiple
436
+ copies and remove the copies from the list of files to consider using the
437
+ imagehash library to detect duplicate or near-duplicate files.
438
+ """
439
+
440
+ print('Checking for duplicate files')
441
+
442
+ # Originals can have upper or lower case extensions, copies only tend to have lower
443
+ # case, so build a lower case to original lookup table
444
+
445
+ names = {name.lower():name for name in media_files}
446
+
447
+ duplicates = defaultdict(list)
448
+
449
+ # Build a list of duplicates for each filename in the list - i.e. files with the same
450
+ # prefix and a suffix matching DUP_RE, indexed by the base filename (without the suffix)
451
+
452
+ for entry in names:
453
+ orig_match = DUP_RE.fullmatch(entry)
454
+ if orig_match:
455
+ original = orig_match.group(1) + orig_match.group(2)
456
+
457
+ duplicates[original].append(entry)
458
+
459
+ # Now use the imagehash library to check each list of maybe-duplicate files
460
+ # to build a list of actual duplicates (or at least nearly-indistinguishable images)
461
+ # TODO: Better to build list of all hashes, then find near-duplicates
462
+
463
+ actual_duplicates = set()
464
+ for entry, dupes in duplicates.items():
465
+ # If the base file (no suffix) exists use that as the base, otherwise
466
+ # use the first duplicate (we can have a situation where we have duplicates
467
+ # and no original).
468
+
469
+ hash_list = defaultdict(list)
470
+
471
+ # Start with the base file, it it exists
472
+
473
+ if entry in names:
474
+ try:
475
+ base_hash = str(imagehash.average_hash(Image.open(names[entry])))
476
+
477
+ hash_list[base_hash].append(names[entry])
478
+ except OSError:
479
+ pass
480
+
481
+ # Calculate the hash of each of the potential duplicates and if they
482
+ # are close enough to the base hash, then add them to the real duplicate list
483
+
484
+ for entry in dupes:
485
+ filename = names[entry]
486
+ try:
487
+ dupe_hash = str(imagehash.average_hash(Image.open(filename)))
488
+
489
+ hash_list[dupe_hash].append(filename)
490
+ except OSError:
491
+ colour.write(f'[BOLD:WARNING]: Unable to read {filename}')
492
+
493
+ # Remove entries with identical hash values
494
+
495
+ for dupes in hash_list:
496
+ for dupe in hash_list[dupes][1:]:
497
+ actual_duplicates.add(dupe)
498
+ hash_list[dupes] = hash_list[dupes][0]
499
+
500
+ # Look for adjaced entries in the sorted list of hash values that differ by less then the minimum
501
+ # and remove the duplicates
502
+
503
+ hash_values = sorted(hash_list.keys())
504
+ logging.debug(f'Hash values for duplicates: {hash_values}')
505
+
506
+ for i in range(len(hash_values)-1):
507
+ if int(hash_values[i+1], 16) - int(hash_values[i], 16) < MIN_HASH_DIFF:
508
+ actual_duplicates.add(hash_list[hash_values[i+1]])
509
+
510
+ # Remove all the entries in the real duplicates list
511
+
512
+ for entry in actual_duplicates:
513
+ logging.info(f'Removing {os.path.basename(entry)} as a (near-)duplicate')
514
+ del media_files[entry]
515
+
516
+ ################################################################################
517
+
518
+ def gphoto_sync(args, year, month):
519
+ """Synchronise a month's worth of photos"""
520
+
521
+ colour.write('[GREEN:%s]' % '-'*80)
522
+ colour.write(f'[BOLD:Reading files for {month:02}/{year}]')
523
+
524
+ # List of directories to search for media associated with this month/year
525
+
526
+ cache_dirs = [cache_directory(args, year, month)]
527
+ local_dirs = [local_directory(args, 'photo', year, month)+'*',
528
+ local_directory(args, 'video', year, month)+'*']
529
+
530
+ # Read the pictures and their EXIF data to get the dates
531
+
532
+ media_files = {'photo': {}, 'video': {}}
533
+ unknown_files = {}
534
+
535
+ media_files['photo']['remote'], media_files['video']['remote'], unknown_files['remote'] = find_files(cache_dirs)
536
+ media_files['photo']['local'], media_files['video']['local'], unknown_files['local'] = find_files(local_dirs)
537
+
538
+ for media in ('photo', 'video'):
539
+ remove_duplicates(media_files[media]['remote'])
540
+
541
+ colour.write('[GREEN:%s]' % '-'*80)
542
+ colour.write(f'[BOLD:Syncing files for {month:02}/{year}]')
543
+
544
+ for media in ('photo', 'video'):
545
+ media_sync(args.dryrun, media, media_files[media], args.local_dir[media])
546
+
547
+ ################################################################################
548
+
549
+ def clear_cache(cache_dir, keep, start):
550
+ """Clear cache entries more than keep months before the start date"""
551
+
552
+ start_name = (start - relativedelta(months=keep)).strftime(DATE_FORMAT)
553
+
554
+ for year_dir in glob.glob(os.path.join(cache_dir, '*')):
555
+ if os.path.isdir(year_dir):
556
+ for month_dir in glob.glob(os.path.join(year_dir, '*')):
557
+ if os.path.isdir(month_dir):
558
+ entry_date = os.path.basename(year_dir) + '-' + os.path.basename(month_dir)
559
+
560
+ if entry_date < start_name:
561
+ logging.info('Removing obsolete cache entry %s', month_dir)
562
+ shutil.rmtree(month_dir)
563
+
564
+ ################################################################################
565
+
566
+ def main():
567
+ """Entry point"""
568
+
569
+ # Handle the command line
570
+
571
+ args = parse_command_line()
572
+
573
+ # Clear old entries from the cache
574
+
575
+ if not args.no_update and not args.dryrun:
576
+ clear_cache(args.cache, args.keep, args.start)
577
+
578
+ # Update the cache
579
+
580
+ for year in range(args.start.year, args.end.year+1):
581
+ start_month = args.start.month if year == args.start.year else 1
582
+ end_month = args.end.month if year == args.end.year else 12
583
+
584
+ for month in range(start_month, end_month+1):
585
+ update_cache(args, year, month)
586
+
587
+ # Perform the sync
588
+
589
+ for year in range(args.start.year, args.end.year+1):
590
+ start_month = args.start.month if year == args.start.year else 1
591
+ end_month = args.end.month if year == args.end.year else 12
592
+
593
+ for month in range(start_month, end_month+1):
594
+ gphoto_sync(args, year, month)
595
+
596
+ ################################################################################
597
+
598
+ def gphotosync():
599
+ """Entry point"""
600
+ try:
601
+ main()
602
+ except KeyboardInterrupt:
603
+ sys.exit(1)
604
+ except BrokenPipeError:
605
+ sys.exit(2)
606
+
607
+ ################################################################################
608
+
609
+ if __name__ == '__main__':
610
+ gphotosync()