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

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skilleter_thingy
3
- Version: 0.1.29
3
+ Version: 0.2.0
4
4
  Summary: A collection of useful utilities, mainly aimed at making Git more friendly
5
5
  Author-email: John Skilleter <john@skilleter.org.uk>
6
6
  Project-URL: Home, https://skilleter.org.uk
@@ -597,55 +597,10 @@ YAML validator - checks that a file is valid YAML (use yamllint to verify that i
597
597
 
598
598
  These will be moved to the skilleter-extras package in due course.
599
599
 
600
- ## borger
601
-
602
- Wrapper for the borg backup utility to make it easier to use with a fixed set of options.
603
-
604
600
  ## consolecolours
605
601
 
606
602
  Display all available colours in the console.
607
603
 
608
- ## diskspacecheck
609
-
610
- Check how much free space is available on all filesystems, ignoring read-only filesystems, /dev and tmpfs.
611
-
612
- Issue a warning if any are above 90% used.
613
-
614
- ## gphotosync & localphotosync
615
-
616
- Utilities for syncing photos from Google Photos or a local directory to local storage
617
-
618
- ## moviemover
619
-
620
- Search for files matching a wildcard in a directory tree and move them to an equivalent location in a different tree
621
-
622
- ## phototidier
623
-
624
- Perform various tidying operations on a directory full of photos:
625
-
626
- * Remove leading '$' and '_' from filenames
627
- * Move files in hidden directories up 1 level
628
- * If the EXIF data in a photo indicates that it was taken on date that doesn't match the name of the directory it is stored in (in YYYY-MM-DD format) then it is moved to the correct directory, creating it if necessary.
629
-
630
- All move/rename operations are carried out safely with the file being moved having
631
- a numeric suffix added to the name if it conflicts with an existing file.
632
-
633
- ## photodupe
634
-
635
- Search for duplicate images in a directory tree
636
-
637
- ## splitpics
638
-
639
- Copy a directory full of pictures to a destination, creating subdiretories with a fixed number of pictures in each in the destination directory for use with FAT filesystems and digital photo frames.
640
-
641
- ## sysmon
642
-
643
- Simple console system monitor
644
-
645
- ## window-rename
646
-
647
- Monitor window titles and rename them to fit an alphabetical grouping in 'Appname - Document' format.
648
-
649
604
  # Obsolescent Commands
650
605
 
651
606
  These commands will probably be retired in future versions of Thingy
@@ -1,8 +1,6 @@
1
1
  skilleter_thingy/__init__.py,sha256=rVPTxm8L5w52U0YdTd7r_D44SBP7pS3JCJtsf0iIsow,110
2
2
  skilleter_thingy/addpath.py,sha256=4Yhhgjjz1XDI98j0dAiQpNA2ejLefeWUTeSg3nIXQq0,3842
3
- skilleter_thingy/borger.py,sha256=voCEgxl-LdDMpCmBxSv3x0XnO9goalO0_T98pM1dqn8,7941
4
3
  skilleter_thingy/console_colours.py,sha256=BOS9mo3jChx_FE8L1j488MDoVNgib11KjTRhrz_YRYE,1781
5
- skilleter_thingy/diskspacecheck.py,sha256=7xsj4egXXV6jPhXZTe2b5rS03XAmm5uLC5TeiO1NJoE,2072
6
4
  skilleter_thingy/docker_purge.py,sha256=PRQ7EBXymjYIHuJL4pk4r6KNn09IF28OGZ0ln57xtNg,3314
7
5
  skilleter_thingy/ffind.py,sha256=rEgotUaMj9JxDCwz-7H5vdqxH_bXllaHqttwsOUGKj8,19235
8
6
  skilleter_thingy/ggit.py,sha256=BL-DhNcz4Nd3sA-3Kl6gZ-zFtbNqOpyufvas-0aD8nk,2465
@@ -23,24 +21,17 @@ skilleter_thingy/gitcmp_helper.py,sha256=NgQ0BZfa4TVA-XV6YKvrm5147boWUpGw-jDPUsk
23
21
  skilleter_thingy/gitprompt.py,sha256=SzSMd0EGI7ftPko80Q2PipwbVA-qjU1jsmdpmTCM5GI,8912
24
22
  skilleter_thingy/gl.py,sha256=9zbGpKxw6lX9RghLkdy-Q5sZlqtbB3uGFO04qTu1dH8,5954
25
23
  skilleter_thingy/linecount.py,sha256=ehTN6VD76i4U5k6dXuYoiqSRHI67_BP-bziklNAJSKY,4309
26
- skilleter_thingy/localphotosync.py,sha256=WF0TcCvLfl7cVOLzYYQK_t2WebLfQ-5FM6UB3r7Fpvw,5952
27
- skilleter_thingy/moviemover.py,sha256=QzUAWQzQ1AWWREIhl-VMaLo2h8MMhOekBnao5jGWV1s,4470
28
24
  skilleter_thingy/multigit.py,sha256=rnQ0YGkEy6H54PRt8nCt-G02LGy-wiKRJVk8ovR_pTw,35179
29
- skilleter_thingy/photodupe.py,sha256=2hw4EhDKH37_BgdXKkPm9GrftfIORmubQi38Yn0b4Mg,4084
30
- skilleter_thingy/phototidier.py,sha256=BOu-cKHMivDlBqlRqv7sL3J6Ix1K2dxWWNcderldyZo,7818
31
25
  skilleter_thingy/py_audit.py,sha256=4CAdqBAIIVcpTCn_7dGm56bdfGpUtUJofqTGZomClkY,4417
32
26
  skilleter_thingy/readable.py,sha256=LcMMOiuzf9j5TsxcMbO0sbj6m1QCuABl91Hrv-YyIww,15422
33
27
  skilleter_thingy/remdir.py,sha256=Ueg3a6_m7y50zWykhKk6pcuz4FKPjoLJVPo9gh_dsic,4653
34
28
  skilleter_thingy/rmdupe.py,sha256=RWtOHq__zY4yOf6_Y-H-8RRJy31Sr3c8DEyTd6Y4oV4,17213
35
29
  skilleter_thingy/rpylint.py,sha256=TzZ5GvWrqgTKYKZwadTvzdbX-DJ8ll4WfVJqtN6IzO0,2635
36
- skilleter_thingy/splitpics.py,sha256=qRlJrqet7TEI6SodS4bkuKXQUpOdMaqmjE4c1CR7ouo,3266
37
30
  skilleter_thingy/strreplace.py,sha256=zMhqC38KF0BddTsRM5Pa99HU3KXvxXg942qxRK-LALA,2539
38
- skilleter_thingy/sysmon.py,sha256=wKbr3paMr62yMw29KglRduMe5fpmaJM4QXUk7orEvLo,11350
39
31
  skilleter_thingy/tfm.py,sha256=xMsqcuNJ32PwKF5vO3SO6etlbJKbCLUJhSdC2w0clwE,33829
40
32
  skilleter_thingy/tfparse.py,sha256=u1IZH2J_WH1aORyMozKSI2JKok7_S1MMJhiobzmhlUI,2988
41
33
  skilleter_thingy/trimpath.py,sha256=ctbV4iydncasuu41qRAmQbuCSUk72dxLUvbSRjEsHKk,2363
42
34
  skilleter_thingy/venv_create.py,sha256=EV_oZh3JlDc5hX5h9T1hnt65AEABw6PufaKvPYabR00,1159
43
- skilleter_thingy/window_rename.py,sha256=dCBgZqih_3YKHt35hsOAhARFp3QxOi8w8huC63sqJK8,3128
44
35
  skilleter_thingy/xchmod.py,sha256=T89xiH_po0nvH5T1AGgQOD5yhjKd9-LcHcmez3IORww,4604
45
36
  skilleter_thingy/yamlcheck.py,sha256=FXylZ5NtHirDlPVhVEUZUZkTugVR-g51BbjaN06akAc,2868
46
37
  skilleter_thingy/thingy/__init__.py,sha256=rVPTxm8L5w52U0YdTd7r_D44SBP7pS3JCJtsf0iIsow,110
@@ -61,9 +52,9 @@ skilleter_thingy/thingy/run.py,sha256=6SNKWF01fSxzB10GMU9ajraXYZqAL1w0PXkqjJdr1U
61
52
  skilleter_thingy/thingy/tfm_pane.py,sha256=XTTpSm71CyQyGmlVLuCthioOwech0jhUiFUXb-chS_Q,19792
62
53
  skilleter_thingy/thingy/tidy.py,sha256=AQ2RawsZJg6WHrgayi_ZptFL9occ7suSdCHbU3P-cys,5971
63
54
  skilleter_thingy/thingy/venv_template.py,sha256=SsVNvSwojd8NnFeQaZPCRQYTNdwJRplpZpygbUEXRnY,1015
64
- skilleter_thingy-0.1.29.dist-info/licenses/LICENSE,sha256=ljOS4DjXvqEo5VzGfdaRwgRZPbNScGBmfwyC8PChvmQ,32422
65
- skilleter_thingy-0.1.29.dist-info/METADATA,sha256=DlnH54zxSd8HRZ1-BUOYZxXZziID__gkX8_N0oeDLkw,30430
66
- skilleter_thingy-0.1.29.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
67
- skilleter_thingy-0.1.29.dist-info/entry_points.txt,sha256=mklrWFvNKw9Hyem9RG3x0PoVYjlx2fDnJ3xWMTMOmfs,2258
68
- skilleter_thingy-0.1.29.dist-info/top_level.txt,sha256=8-JhgToBBiWURunmvfpSxEvNkDHQQ7r25-aBXtZv61g,17
69
- skilleter_thingy-0.1.29.dist-info/RECORD,,
55
+ skilleter_thingy-0.2.0.dist-info/licenses/LICENSE,sha256=ljOS4DjXvqEo5VzGfdaRwgRZPbNScGBmfwyC8PChvmQ,32422
56
+ skilleter_thingy-0.2.0.dist-info/METADATA,sha256=lfOs1rUsUbK947Haf9nIzzFzvB3SR41pYf6LGpcGGEY,28913
57
+ skilleter_thingy-0.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
58
+ skilleter_thingy-0.2.0.dist-info/entry_points.txt,sha256=MTNWf8jOx8Fy3tSwVLCZPlEyzlDF36odw-IN-cSefP8,1784
59
+ skilleter_thingy-0.2.0.dist-info/top_level.txt,sha256=8-JhgToBBiWURunmvfpSxEvNkDHQQ7r25-aBXtZv61g,17
60
+ skilleter_thingy-0.2.0.dist-info/RECORD,,
@@ -1,8 +1,6 @@
1
1
  [console_scripts]
2
2
  addpath = skilleter_thingy:addpath.addpath
3
- borger = skilleter_thingy:borger.borger
4
3
  consolecolours = skilleter_thingy:console_colours.console_colours
5
- diskspacecheck = skilleter_thingy:diskspacecheck.diskspacecheck
6
4
  docker-purge = skilleter_thingy:docker_purge.docker_purge
7
5
  ffind = skilleter_thingy:ffind.ffind
8
6
  ggit = skilleter_thingy:ggit.ggit
@@ -24,26 +22,19 @@ gitprompt = skilleter_thingy:gitprompt.gitprompt
24
22
  gl = skilleter_thingy:gl.gl
25
23
  gphotosync = skilleter_thingy:gphotosync.gphotosync
26
24
  linecount = skilleter_thingy:linecount.linecount
27
- localphotosync = skilleter_thingy:localphotosync.localphotosync
28
25
  mg = skilleter_thingy:mg.mg
29
- moviemover = skilleter_thingy:moviemover.moviemover
30
26
  multigit = skilleter_thingy:multigit.multigit
31
- photodupe = skilleter_thingy:photodupe.photodupe
32
- phototidier = skilleter_thingy:phototidier.phototidier
33
27
  py-audit = skilleter_thingy:py_audit.py_audit
34
28
  readable = skilleter_thingy:readable.readable
35
29
  remdir = skilleter_thingy:remdir.remdir
36
30
  rmdupe = skilleter_thingy:rmdupe.rmdupe
37
31
  rpylint = skilleter_thingy:rpylint.rpylint
38
32
  s3-sync = skilleter_thingy:s3_sync.s3_sync
39
- splitpics = skilleter_thingy:splitpics.splitpics
40
33
  strreplace = skilleter_thingy:strreplace.strreplace
41
- sysmon = skilleter_thingy:sysmon.sysmon
42
34
  tfm = skilleter_thingy:tfm.tfm
43
35
  tfparse = skilleter_thingy:tfparse.tfparse
44
36
  trimpath = skilleter_thingy:trimpath.trimpath
45
37
  venv-create = skilleter_thingy:venv_create.venv_create
46
38
  webwatch = skilleter_thingy:webwatch.webwatch
47
- window-rename = skilleter_thingy:window_rename.window_rename
48
39
  xchmod = skilleter_thingy:xchmod.xchmod
49
40
  yamlcheck = skilleter_thingy:yamlcheck.yamlcheck
@@ -1,273 +0,0 @@
1
- #! /usr/bin/env python3
2
-
3
- """
4
- Wrapper for the borg backup command
5
-
6
- Copyright (C) 2018 John Skilleter
7
-
8
- TODO: Major tidy-up as this is a translation of a Bash script.
9
- TODO: Merge with the usb-backup script since both do almost the same job
10
- TODO: Default configuration file should be named for the hostname
11
- TODO: Move all configuration data into the configuration file
12
- """
13
-
14
- ################################################################################
15
- # Imports
16
-
17
- import sys
18
- import os
19
- import time
20
- import argparse
21
- import configparser
22
- import subprocess
23
- from pathlib import Path
24
-
25
- ################################################################################
26
- # Variables
27
-
28
- DEFAULT_CONFIG_FILE = Path('borger.ini')
29
-
30
- COMMANDS = ('backup', 'mount', 'umount', 'compact', 'info', 'prune', 'check', 'init')
31
-
32
- # TODO: NOT USED
33
- PRUNE_OPTIONS = [
34
- '--keep-within', '7d',
35
- '--keep-daily', '30',
36
- '--keep-weekly', '26',
37
- '--keep-monthly', '24',
38
- '--keep-yearly', '10',
39
- ]
40
-
41
- ################################################################################
42
-
43
- def run(args, cmd):
44
- """Run a subprocess."""
45
-
46
- if args.debug:
47
- cmd_str = ' '.join(cmd)
48
- print(f'Running "{cmd_str}"')
49
-
50
- try:
51
- return subprocess.run(cmd, check=True)
52
- except FileNotFoundError:
53
- print('Borg backup is not installed')
54
- sys.exit(1)
55
-
56
- ################################################################################
57
-
58
- def borg_backup(args, exclude_list):
59
- """Perform a backup."""
60
-
61
- create_options = ['--compression', 'auto,lzma']
62
-
63
- version = time.strftime('%Y-%m-%d-%H:%M:%S')
64
-
65
- print(f'Creating backup version {version}')
66
-
67
- if args.verbose:
68
- create_options += ['--list', '--filter=AMC']
69
-
70
- if args.dryrun:
71
- create_options.append('--dry-run')
72
- else:
73
- create_options.append('--stats')
74
-
75
- exclude_opts = []
76
-
77
- if exclude_list:
78
- for exclude in exclude_list:
79
- exclude_opts += ['--exclude', exclude]
80
-
81
- os.chdir(args.source)
82
-
83
- run(args,
84
- ['borg'] + args.options + ['create', f'{str(args.destination)}::{version}', str(args.source)] + create_options +
85
- ['--show-rc', '--one-file-system', '--exclude-caches'] + exclude_opts)
86
-
87
- ################################################################################
88
-
89
- def borg_prune(args):
90
- """Prune the repo by limiting the number of backups stored."""
91
-
92
- print('Pruning old backups')
93
-
94
- # Keep all backups for at least 7 days, 1 per day for 30 days, 1 per week for 2 years
95
- # 1 per month for 4 years and 1 per year for 10 years.
96
-
97
- run(args, ['borg'] + args.options + ['prune', str(args.destination)] + PRUNE_OPTIONS)
98
-
99
- ################################################################################
100
-
101
- def borg_compact(args):
102
- """Compact the repo."""
103
-
104
- print('Compacting the backup')
105
-
106
- # Keep all backups for at least 7 days, 1 per day for 30 days, 1 per week for 2 years
107
- # 1 per month for 4 years and 1 per year for 10 years.
108
-
109
- run(args, ['borg'] + args.options + ['compact', str(args.destination)])
110
-
111
- ################################################################################
112
-
113
- def borg_info(args):
114
- """Info."""
115
-
116
- run(args, ['borg'] + args.options + ['info', str(args.destination)])
117
-
118
- ################################################################################
119
-
120
- def borg_mount(args):
121
- """Mount."""
122
-
123
- print(f'Mounting Borg backups at {args.mount_dir}')
124
-
125
- mount = Path(args.mount_dir)
126
-
127
- if not mount.is_dir():
128
- mount.mkdir()
129
-
130
- run(args, ['borg'] + args.options + ['mount', str(args.destination), str(mount)])
131
-
132
- ################################################################################
133
-
134
- def borg_umount(args):
135
- """Unmount."""
136
-
137
- print('Unmounting {args.mount}')
138
-
139
- run(args, ['borg'] + args.options + ['umount', str(args.mount)])
140
-
141
- ################################################################################
142
-
143
- def borg_check(args):
144
- """Check the status of a backup."""
145
-
146
- run(args, ['borg'] + args.options + ['check', str(args.destination)])
147
-
148
- ################################################################################
149
-
150
- def borg_init(args):
151
- """Initialise a backup."""
152
-
153
- run(args, ['borg'] + args.options + ['init', str(args.destination), '--encryption=none'])
154
-
155
- ################################################################################
156
-
157
- def process_excludes(exclude_data):
158
- """Process the include list from the configuration file."""
159
-
160
- return exclude_data.replace('%', str(Path.cwd())).split('\n')
161
-
162
- ################################################################################
163
-
164
- def main():
165
- """Entry point."""
166
-
167
- command_list = ', '.join(COMMANDS)
168
-
169
- parser = argparse.ArgumentParser(description='Wrapper app for Borg backup to make it easier to use')
170
- parser.add_argument('--dryrun', '--dry-run', '-D', action='store_true', help='Dry-run comands')
171
- parser.add_argument('--debug', '-d', action='store_true', help='Debug')
172
- parser.add_argument('--verbose', '-v', action='store_true', help='Verbosity to the maximum')
173
- parser.add_argument('--config', '-c', default=None, help='Specify the configuration file')
174
- parser.add_argument('commands', nargs='+', help=f'One or more commands ({command_list})')
175
- args = parser.parse_args()
176
-
177
- # If no config file specified then look in all the usual places
178
-
179
- if args.config:
180
- args.config = Path(args.config)
181
- elif DEFAULT_CONFIG_FILE.is_file():
182
- args.config = DEFAULT_CONFIG_FILE
183
- else:
184
- args.config = Path.home() / DEFAULT_CONFIG_FILE
185
-
186
- if not args.config.is_file():
187
- args.config = Path(sys.argv[0]).parent / DEFAULT_CONFIG_FILE
188
-
189
- # Check that the configuration file exists
190
-
191
- if not args.config.is_file():
192
- print(f'Configuration file "{args.config}" not found')
193
- sys.exit(1)
194
-
195
- # Default options
196
-
197
- args.options = []
198
-
199
- # Read the configuration file
200
-
201
- config = configparser.ConfigParser(interpolation=configparser.ExtendedInterpolation())
202
- config.read(args.config)
203
-
204
- if 'borger' not in config:
205
- print('Invalid configuration file "args.config"')
206
- sys.exit(1)
207
-
208
- exclude = process_excludes(config['borger']['exclude']) if 'exclude' in config['borger'] else []
209
-
210
- if 'prune' in config['borger']:
211
- # TODO: Stuff
212
- print('Parser for the prune option is not implemented yet')
213
- sys.exit(1)
214
-
215
- if 'destination' in config['borger']:
216
- args.destination = config['borger']['destination']
217
- else:
218
- print('Destination directory not specified')
219
- sys.exit(1)
220
-
221
- if 'source' in config['borger']:
222
- args.source = Path(config['borger']['source'])
223
- else:
224
- print('Source directory not specified')
225
- sys.exit(1)
226
-
227
- # Initialise if necessary
228
-
229
- if args.debug:
230
- args.options.append('--verbose')
231
-
232
- if args.verbose:
233
- args.options.append('--progress')
234
-
235
- # Decide what to do
236
-
237
- for command in args.commands:
238
- if command == 'backup':
239
- borg_backup(args, exclude)
240
- elif command == 'mount':
241
- borg_mount(args)
242
- elif command == 'umount':
243
- borg_umount(args)
244
- elif command == 'info':
245
- borg_info(args)
246
- elif command == 'prune':
247
- borg_prune(args)
248
- elif command == 'check':
249
- borg_check(args)
250
- elif command == 'init':
251
- borg_init(args)
252
- elif command == 'compact':
253
- borg_compact(args)
254
- else:
255
- print(f'Unrecognized command: {command}')
256
- sys.exit(2)
257
-
258
- ################################################################################
259
-
260
- def borger():
261
- """Entry point"""
262
-
263
- try:
264
- main()
265
- except KeyboardInterrupt:
266
- sys.exit(1)
267
- except BrokenPipeError:
268
- sys.exit(2)
269
-
270
- ################################################################################
271
-
272
- if __name__ == '__main__':
273
- borger()
@@ -1,67 +0,0 @@
1
- #! /usr/bin/env python3
2
-
3
- ################################################################################
4
- """ Check how much free space is available on all filesystems, ignoring
5
- read-only filesystems, /dev and tmpfs.
6
-
7
- Issue a warning if any are above 90% used.
8
- """
9
- ################################################################################
10
-
11
- import sys
12
- import argparse
13
- import psutil
14
-
15
- ################################################################################
16
-
17
- WARNING_LEVEL = 15
18
-
19
- ################################################################################
20
-
21
- def main():
22
- """ Do everything """
23
-
24
- parser = argparse.ArgumentParser(description='Check for filesystems that are running low on space')
25
- parser.add_argument('--level', action='store', type=int, default=WARNING_LEVEL,
26
- help='Warning if less than this amount of space is available on any writeable, mounted filesystem (default=%d)' % WARNING_LEVEL)
27
- args = parser.parse_args()
28
-
29
- if args.level < 0 or args.level > 100:
30
- print('Invalid value: %d' % args.level)
31
- sys.exit(3)
32
-
33
- disks = psutil.disk_partitions()
34
- devices = []
35
- warning = []
36
-
37
- for disk in disks:
38
- if 'ro' not in disk.opts.split(',') and disk.device not in devices:
39
- devices.append(disk.device)
40
- usage = psutil.disk_usage(disk.mountpoint)
41
-
42
- disk_space = 100 - usage.percent
43
-
44
- if disk_space < args.level:
45
- warning.append('%s has only %2.1f%% space available' % (disk.mountpoint, disk_space))
46
-
47
- if warning:
48
- print('Filesystems with less than %d%% available space:' % args.level)
49
- print('\n'.join(warning))
50
-
51
- ################################################################################
52
-
53
- def diskspacecheck():
54
- """Entry point"""
55
-
56
- try:
57
- main()
58
-
59
- except KeyboardInterrupt:
60
- sys.exit(1)
61
- except BrokenPipeError:
62
- sys.exit(2)
63
-
64
- ################################################################################
65
-
66
- if __name__ == '__main__':
67
- diskspacecheck()
@@ -1,191 +0,0 @@
1
- #!/usr/bin/env python3
2
-
3
- """
4
- Sync a directory tree full of photos into a tree organised by year, month and date
5
- """
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
-
10
- import os
11
- import glob
12
- import shutil
13
- import sys
14
- import logging
15
- import argparse
16
- import re
17
-
18
- from enum import Enum
19
-
20
- ################################################################################
21
-
22
- # Default locations for local storage of photos and videos
23
-
24
- DEFAULT_PHOTO_DIR = os.path.expanduser('~/Pictures')
25
- DEFAULT_VIDEO_DIR = os.path.expanduser('~/Videos')
26
-
27
- # File extensions (case-insensitive)
28
-
29
- IMAGE_EXTENSIONS = ('.jpg', '.jpeg', '.png')
30
- VIDEO_EXTENSIONS = ('.mp4', '.mov')
31
-
32
- # Enum of filetypes
33
-
34
- class FileType(Enum):
35
- """File types"""
36
- IMAGE = 0
37
- VIDEO = 1
38
- UNKNOWN = 2
39
- IGNORE = 3
40
-
41
- ################################################################################
42
-
43
- def error(msg, status=1):
44
- """Exit with an error message"""
45
-
46
- print(msg)
47
- sys.exit(status)
48
-
49
- ################################################################################
50
-
51
- def parse_command_line():
52
- """Parse and validate the command line options"""
53
-
54
- parser = argparse.ArgumentParser(description='Sync photos from Google Photos')
55
-
56
- parser.add_argument('--verbose', '-v', action='store_true', help='Output verbose status information')
57
- parser.add_argument('--dryrun', '--dry-run', '-D', action='store_true', help='Just list files to be copied, without actually copying them')
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})')
62
- parser.add_argument('--path', '-p', action='store', default=None, help='Path to sync from')
63
-
64
- args = parser.parse_args()
65
-
66
- if not args.path:
67
- error('You must specify a source directory')
68
-
69
- # Configure debugging
70
-
71
- logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO)
72
-
73
- # Report parameters if verbose
74
-
75
- logging.debug('Source: %s', args.path)
76
- logging.debug('Pictures: %s', args.picturedir)
77
- logging.debug('Videos: %s', args.videodir)
78
- logging.debug('Dry run: %d', args.dryrun)
79
-
80
- return args
81
-
82
- ################################################################################
83
-
84
- def get_filetype(filename):
85
- """Return the type of a file"""
86
-
87
- _, ext = os.path.splitext(filename)
88
-
89
- ext = ext.lower()
90
-
91
- if ext in IMAGE_EXTENSIONS:
92
- return FileType.IMAGE
93
-
94
- if ext in VIDEO_EXTENSIONS:
95
- return FileType.VIDEO
96
-
97
- return FileType.UNKNOWN
98
-
99
- ################################################################################
100
-
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
127
-
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
132
-
133
- year = date_match.group(2)
134
- month = date_match.group(3)
135
- day = date_match.group(4)
136
-
137
- default_dest_dir = f'{dest_dir}/{year}/{year}-{month}-{day}'
138
- dest_dir_pattern = f'{default_dest_dir}*'
139
-
140
- dest_dirs = [path for path in glob.glob(dest_dir_pattern) if os.path.isdir(path)]
141
-
142
- sourcefile_name = os.path.basename(sourcefile)
143
-
144
- # Search any matching destination directories to see if the file exists
145
-
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
150
- else:
151
- dest_dir = sorted(dest_dirs)[0]
152
- else:
153
- if not args.dryrun:
154
- os.makedirs(default_dest_dir)
155
-
156
- dest_dir = default_dest_dir
157
-
158
- dest_file = os.path.join(dest_dir, sourcefile_name)
159
-
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)
164
-
165
- if not args.dryrun:
166
- shutil.copyfile(sourcefile, dest_file)
167
-
168
- files_copied += 1
169
-
170
- if files_copied:
171
- print(f'{files_copied} files copied')
172
-
173
- ################################################################################
174
-
175
- def localphotosync():
176
- """Entry point"""
177
- try:
178
- args = parse_command_line()
179
-
180
- media_sync(args)
181
-
182
- except KeyboardInterrupt:
183
- sys.exit(1)
184
-
185
- except BrokenPipeError:
186
- sys.exit(2)
187
-
188
- ################################################################################
189
-
190
- if __name__ == '__main__':
191
- localphotosync()