skilleter-thingy 0.2.1__py3-none-any.whl → 0.2.3__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/ggit.py +0 -1
- skilleter_thingy/ggrep.py +0 -1
- skilleter_thingy/git_br.py +0 -7
- skilleter_thingy/git_ca.py +0 -8
- skilleter_thingy/git_cleanup.py +0 -11
- skilleter_thingy/git_co.py +3 -8
- skilleter_thingy/git_common.py +4 -12
- skilleter_thingy/git_hold.py +0 -9
- skilleter_thingy/git_mr.py +0 -11
- skilleter_thingy/git_parent.py +18 -23
- skilleter_thingy/git_retag.py +0 -10
- skilleter_thingy/git_retag.sync-conflict-20250928-192600-TVSLRWK.py +54 -0
- skilleter_thingy/git_review.py +0 -1
- skilleter_thingy/git_update.py +0 -1
- skilleter_thingy/git_wt.py +0 -2
- skilleter_thingy/gitprompt.py +0 -1
- skilleter_thingy/thingy/git.py +5 -18
- skilleter_thingy/thingy/git2.py +7 -20
- {skilleter_thingy-0.2.1.dist-info → skilleter_thingy-0.2.3.dist-info}/METADATA +1 -46
- skilleter_thingy-0.2.3.dist-info/PKG-INFO 2 +193 -0
- {skilleter_thingy-0.2.1.dist-info → skilleter_thingy-0.2.3.dist-info}/RECORD +25 -32
- {skilleter_thingy-0.2.1.dist-info → skilleter_thingy-0.2.3.dist-info}/entry_points.txt +0 -9
- skilleter_thingy/borger.py +0 -273
- skilleter_thingy/diskspacecheck.py +0 -67
- skilleter_thingy/localphotosync.py +0 -201
- skilleter_thingy/moviemover.py +0 -133
- skilleter_thingy/photodupe.py +0 -135
- skilleter_thingy/phototidier.py +0 -248
- skilleter_thingy/splitpics.py +0 -99
- skilleter_thingy/sysmon.py +0 -435
- skilleter_thingy/window_rename.py +0 -92
- {skilleter_thingy-0.2.1.dist-info → skilleter_thingy-0.2.3.dist-info}/WHEEL +0 -0
- {skilleter_thingy-0.2.1.dist-info → skilleter_thingy-0.2.3.dist-info}/licenses/LICENSE +0 -0
- {skilleter_thingy-0.2.1.dist-info → skilleter_thingy-0.2.3.dist-info}/top_level.txt +0 -0
|
@@ -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,201 +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
|
-
import datetime
|
|
18
|
-
|
|
19
|
-
from enum import Enum
|
|
20
|
-
|
|
21
|
-
################################################################################
|
|
22
|
-
|
|
23
|
-
# Default locations for local storage of photos and videos
|
|
24
|
-
|
|
25
|
-
DEFAULT_PHOTO_DIR = os.path.expanduser('~/Pictures')
|
|
26
|
-
DEFAULT_VIDEO_DIR = os.path.expanduser('~/Videos')
|
|
27
|
-
|
|
28
|
-
# File extensions (case-insensitive)
|
|
29
|
-
|
|
30
|
-
IMAGE_EXTENSIONS = ('.jpg', '.jpeg', '.png')
|
|
31
|
-
VIDEO_EXTENSIONS = ('.mp4', '.mov')
|
|
32
|
-
|
|
33
|
-
# Enum of filetypes
|
|
34
|
-
|
|
35
|
-
class FileType(Enum):
|
|
36
|
-
"""File types"""
|
|
37
|
-
IMAGE = 0
|
|
38
|
-
VIDEO = 1
|
|
39
|
-
UNKNOWN = 2
|
|
40
|
-
IGNORE = 3
|
|
41
|
-
|
|
42
|
-
################################################################################
|
|
43
|
-
|
|
44
|
-
def error(msg, status=1):
|
|
45
|
-
"""Exit with an error message"""
|
|
46
|
-
|
|
47
|
-
print(msg)
|
|
48
|
-
sys.exit(status)
|
|
49
|
-
|
|
50
|
-
################################################################################
|
|
51
|
-
|
|
52
|
-
def parse_command_line():
|
|
53
|
-
"""Parse and validate the command line options"""
|
|
54
|
-
|
|
55
|
-
parser = argparse.ArgumentParser(description='Sync photos from Google Photos')
|
|
56
|
-
|
|
57
|
-
parser.add_argument('--verbose', '-v', action='store_true', help='Output verbose status information')
|
|
58
|
-
parser.add_argument('--dryrun', '--dry-run', '-D', action='store_true', help='Just list files to be copied, without actually copying them')
|
|
59
|
-
parser.add_argument('--picturedir', '-P', action='store', default=DEFAULT_PHOTO_DIR,
|
|
60
|
-
help=f'Location of local picture storage directory (defaults to {DEFAULT_PHOTO_DIR})')
|
|
61
|
-
parser.add_argument('--videodir', '-V', action='store', default=DEFAULT_VIDEO_DIR,
|
|
62
|
-
help=f'Location of local video storage directory (defaults to {DEFAULT_VIDEO_DIR})')
|
|
63
|
-
parser.add_argument('--path', '-p', action='store', default=None, help='Path to sync from')
|
|
64
|
-
|
|
65
|
-
args = parser.parse_args()
|
|
66
|
-
|
|
67
|
-
if not args.path:
|
|
68
|
-
error('You must specify a source directory')
|
|
69
|
-
|
|
70
|
-
# Configure debugging
|
|
71
|
-
|
|
72
|
-
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO)
|
|
73
|
-
|
|
74
|
-
# Report parameters if verbose
|
|
75
|
-
|
|
76
|
-
logging.debug('Source: %s', args.path)
|
|
77
|
-
logging.debug('Pictures: %s', args.picturedir)
|
|
78
|
-
logging.debug('Videos: %s', args.videodir)
|
|
79
|
-
logging.debug('Dry run: %d', args.dryrun)
|
|
80
|
-
|
|
81
|
-
return args
|
|
82
|
-
|
|
83
|
-
################################################################################
|
|
84
|
-
|
|
85
|
-
def get_filetype(filename):
|
|
86
|
-
"""Return the type of a file"""
|
|
87
|
-
|
|
88
|
-
_, ext = os.path.splitext(filename)
|
|
89
|
-
|
|
90
|
-
ext = ext.lower()
|
|
91
|
-
|
|
92
|
-
if ext in IMAGE_EXTENSIONS:
|
|
93
|
-
return FileType.IMAGE
|
|
94
|
-
|
|
95
|
-
if ext in VIDEO_EXTENSIONS:
|
|
96
|
-
return FileType.VIDEO
|
|
97
|
-
|
|
98
|
-
return FileType.UNKNOWN
|
|
99
|
-
|
|
100
|
-
################################################################################
|
|
101
|
-
|
|
102
|
-
def media_sync(args):
|
|
103
|
-
"""Sync photos and videos from args.path to date-structured directory
|
|
104
|
-
trees in args.picturedir and args.videodir.
|
|
105
|
-
Assumes that the source files are in Android naming format:
|
|
106
|
-
(IMG|VID)_YYYYMMDD_*.(jpg|mp4)
|
|
107
|
-
Looks for a destination directory called:
|
|
108
|
-
YYYY/YYYY-MM-DD*/
|
|
109
|
-
If multiple destination directories exist, it uses the first one when the
|
|
110
|
-
names are sorted alphbetically
|
|
111
|
-
If a file with the same name exists in the destination directory it is
|
|
112
|
-
not overwritten"""
|
|
113
|
-
|
|
114
|
-
files_copied = 0
|
|
115
|
-
|
|
116
|
-
filetype_re = re.compile(r'(PANO|IMG|VID)[-_](\d{4})(\d{2})(\d{2})[-_.].*')
|
|
117
|
-
|
|
118
|
-
for sourcefile in [source for source in glob.glob(os.path.join(args.path, '*')) if os.path.isfile(source)]:
|
|
119
|
-
filetype = get_filetype(sourcefile)
|
|
120
|
-
|
|
121
|
-
if filetype == FileType.IMAGE:
|
|
122
|
-
dest_dir = args.picturedir
|
|
123
|
-
elif filetype == FileType.VIDEO:
|
|
124
|
-
dest_dir = args.videodir
|
|
125
|
-
else:
|
|
126
|
-
logging.info('Ignoring %s - unable to determine file type', sourcefile)
|
|
127
|
-
continue
|
|
128
|
-
|
|
129
|
-
date_match = filetype_re.fullmatch(os.path.basename(sourcefile))
|
|
130
|
-
if date_match:
|
|
131
|
-
year = date_match.group(2)
|
|
132
|
-
month = date_match.group(3)
|
|
133
|
-
day = date_match.group(4)
|
|
134
|
-
|
|
135
|
-
default_dest_dir = f'{dest_dir}/{year}/{year}-{month}-{day}'
|
|
136
|
-
else:
|
|
137
|
-
logging.debug('Ignoring %s - unable to extract date from filename - using last-modified date', sourcefile)
|
|
138
|
-
|
|
139
|
-
timestamp = os.path.getmtime(sourcefile)
|
|
140
|
-
last_modified_date = datetime.datetime.fromtimestamp(timestamp)
|
|
141
|
-
|
|
142
|
-
year = last_modified_date.year
|
|
143
|
-
month = last_modified_date.month
|
|
144
|
-
day = last_modified_date.day
|
|
145
|
-
|
|
146
|
-
default_dest_dir = f'{dest_dir}/{year:04}/{year:04}-{month:02}-{day:02}'
|
|
147
|
-
|
|
148
|
-
dest_dir_pattern = f'{default_dest_dir}*'
|
|
149
|
-
|
|
150
|
-
dest_dirs = [path for path in glob.glob(dest_dir_pattern) if os.path.isdir(path)]
|
|
151
|
-
|
|
152
|
-
sourcefile_name = os.path.basename(sourcefile)
|
|
153
|
-
|
|
154
|
-
# Search any matching destination directories to see if the file exists
|
|
155
|
-
|
|
156
|
-
if dest_dirs:
|
|
157
|
-
for dest_dir in dest_dirs:
|
|
158
|
-
if os.path.isfile(os.path.join(dest_dir, sourcefile_name)):
|
|
159
|
-
break
|
|
160
|
-
else:
|
|
161
|
-
dest_dir = sorted(dest_dirs)[0]
|
|
162
|
-
else:
|
|
163
|
-
if not args.dryrun:
|
|
164
|
-
os.makedirs(default_dest_dir)
|
|
165
|
-
|
|
166
|
-
dest_dir = default_dest_dir
|
|
167
|
-
|
|
168
|
-
dest_file = os.path.join(dest_dir, sourcefile_name)
|
|
169
|
-
|
|
170
|
-
if os.path.exists(dest_file):
|
|
171
|
-
logging.debug('Destination file %s already exists', dest_file)
|
|
172
|
-
else:
|
|
173
|
-
logging.info('Copying %s to %s', sourcefile, dest_file)
|
|
174
|
-
|
|
175
|
-
if not args.dryrun:
|
|
176
|
-
shutil.copyfile(sourcefile, dest_file)
|
|
177
|
-
|
|
178
|
-
files_copied += 1
|
|
179
|
-
|
|
180
|
-
if files_copied:
|
|
181
|
-
print(f'{files_copied} files copied')
|
|
182
|
-
|
|
183
|
-
################################################################################
|
|
184
|
-
|
|
185
|
-
def localphotosync():
|
|
186
|
-
"""Entry point"""
|
|
187
|
-
try:
|
|
188
|
-
args = parse_command_line()
|
|
189
|
-
|
|
190
|
-
media_sync(args)
|
|
191
|
-
|
|
192
|
-
except KeyboardInterrupt:
|
|
193
|
-
sys.exit(1)
|
|
194
|
-
|
|
195
|
-
except BrokenPipeError:
|
|
196
|
-
sys.exit(2)
|
|
197
|
-
|
|
198
|
-
################################################################################
|
|
199
|
-
|
|
200
|
-
if __name__ == '__main__':
|
|
201
|
-
localphotosync()
|
skilleter_thingy/moviemover.py
DELETED
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
#! /usr/bin/env python3
|
|
2
|
-
|
|
3
|
-
""" Search for files matching a wildcard in a directory tree and move them to an
|
|
4
|
-
equivalent location in a different tree """
|
|
5
|
-
|
|
6
|
-
import argparse
|
|
7
|
-
import os
|
|
8
|
-
import sys
|
|
9
|
-
import glob
|
|
10
|
-
import pathlib
|
|
11
|
-
import shutil
|
|
12
|
-
import filecmp
|
|
13
|
-
|
|
14
|
-
################################################################################
|
|
15
|
-
|
|
16
|
-
def error(msg, status=1):
|
|
17
|
-
""" Exit with an error message """
|
|
18
|
-
|
|
19
|
-
print(msg)
|
|
20
|
-
|
|
21
|
-
sys.exit(status)
|
|
22
|
-
|
|
23
|
-
################################################################################
|
|
24
|
-
|
|
25
|
-
def parse_command_line():
|
|
26
|
-
""" Handle command line arguments """
|
|
27
|
-
|
|
28
|
-
parser = argparse.ArgumentParser(description='File relocation - move files by wildcard from one directory tree to another')
|
|
29
|
-
|
|
30
|
-
parser.add_argument('--source', '-s', type=str, required=True, help='Source directory')
|
|
31
|
-
parser.add_argument('--destination', '-d', type=str, required=True, help='Destination directory')
|
|
32
|
-
parser.add_argument('--dry-run', '-D', action='store_true', help='Report what files would be moved, without actually moving them')
|
|
33
|
-
parser.add_argument('files', nargs='*', help='List of wildcard matches')
|
|
34
|
-
|
|
35
|
-
args = parser.parse_args()
|
|
36
|
-
|
|
37
|
-
if not args.files:
|
|
38
|
-
print('You must specify at least one wildcard/regex parameter')
|
|
39
|
-
|
|
40
|
-
if not os.path.isdir(args.source):
|
|
41
|
-
error(f'{args.source} is not a directory')
|
|
42
|
-
|
|
43
|
-
if not os.path.isdir(args.destination):
|
|
44
|
-
error(f'{args.destination} is not a directory')
|
|
45
|
-
|
|
46
|
-
args.source_path = pathlib.Path(os.path.realpath(args.source))
|
|
47
|
-
args.destination_path = pathlib.Path(os.path.realpath(args.destination))
|
|
48
|
-
|
|
49
|
-
if args.source_path == args.destination_path:
|
|
50
|
-
error('Source and destination paths cannot be the same')
|
|
51
|
-
|
|
52
|
-
if args.source_path in args.destination_path.parents:
|
|
53
|
-
error('The destination directory cannot be within the source path')
|
|
54
|
-
|
|
55
|
-
if args.destination_path in args.source_path.parents:
|
|
56
|
-
error('The source directory cannot be within the destination path')
|
|
57
|
-
|
|
58
|
-
return args
|
|
59
|
-
|
|
60
|
-
################################################################################
|
|
61
|
-
|
|
62
|
-
def main():
|
|
63
|
-
""" Entry point """
|
|
64
|
-
|
|
65
|
-
args = parse_command_line()
|
|
66
|
-
|
|
67
|
-
# Process each wildcard
|
|
68
|
-
|
|
69
|
-
for wild in args.files:
|
|
70
|
-
# Find matching files in the source tree
|
|
71
|
-
|
|
72
|
-
for source_file in args.source_path.glob(f'**/{wild}'):
|
|
73
|
-
# Ignore anything that isn't a file
|
|
74
|
-
|
|
75
|
-
if source_file.is_file():
|
|
76
|
-
# Determine where to put it
|
|
77
|
-
|
|
78
|
-
dest_file = args.destination_path / source_file.relative_to(args.source_path)
|
|
79
|
-
|
|
80
|
-
if dest_file.exists():
|
|
81
|
-
|
|
82
|
-
if filecmp.cmp(source_file, dest_file, shallow=False):
|
|
83
|
-
print(f'Destination file {dest_file} already exists and is identical, so deleting source')
|
|
84
|
-
if not args.dry_run:
|
|
85
|
-
os.unlink(source_file)
|
|
86
|
-
else:
|
|
87
|
-
print(f'Destination file {dest_file} already exists and is DIFFERENT')
|
|
88
|
-
else:
|
|
89
|
-
# If the destination directory doesn't exist, then create it
|
|
90
|
-
|
|
91
|
-
if not dest_file.parent.is_dir():
|
|
92
|
-
print(f'Creating directory {dest_file.parent}')
|
|
93
|
-
|
|
94
|
-
if not args.dry_run:
|
|
95
|
-
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
-
|
|
97
|
-
# Move the file
|
|
98
|
-
|
|
99
|
-
print(f'Moving {source_file.name} to {dest_file.parent}')
|
|
100
|
-
|
|
101
|
-
if not args.dry_run:
|
|
102
|
-
try:
|
|
103
|
-
shutil.move(source_file, dest_file)
|
|
104
|
-
except PermissionError:
|
|
105
|
-
print(f'WARNING: Permissions error moving {source_file}')
|
|
106
|
-
|
|
107
|
-
# Delete the source directory if it is not empty
|
|
108
|
-
|
|
109
|
-
source_dir = os.path.dirname(source_file)
|
|
110
|
-
|
|
111
|
-
if not glob.glob(source_dir, recursive=True):
|
|
112
|
-
print('Deleting directory "{source_dir}"')
|
|
113
|
-
if not args.dry_run:
|
|
114
|
-
os.path.unlink(source_dir)
|
|
115
|
-
|
|
116
|
-
################################################################################
|
|
117
|
-
|
|
118
|
-
def moviemover():
|
|
119
|
-
"""Entry point"""
|
|
120
|
-
|
|
121
|
-
try:
|
|
122
|
-
main()
|
|
123
|
-
|
|
124
|
-
except KeyboardInterrupt:
|
|
125
|
-
sys.exit(1)
|
|
126
|
-
|
|
127
|
-
except BrokenPipeError:
|
|
128
|
-
sys.exit(2)
|
|
129
|
-
|
|
130
|
-
################################################################################
|
|
131
|
-
|
|
132
|
-
if __name__ == '__main__':
|
|
133
|
-
moviemover()
|
skilleter_thingy/photodupe.py
DELETED
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Hash photos to find closely-similar images and report them"""
|
|
3
|
-
|
|
4
|
-
import sys
|
|
5
|
-
import os
|
|
6
|
-
import pickle
|
|
7
|
-
import argparse
|
|
8
|
-
|
|
9
|
-
import PIL
|
|
10
|
-
|
|
11
|
-
from collections import defaultdict
|
|
12
|
-
|
|
13
|
-
from PIL import Image
|
|
14
|
-
import imagehash
|
|
15
|
-
|
|
16
|
-
################################################################################
|
|
17
|
-
|
|
18
|
-
def read_image_hashes(directories):
|
|
19
|
-
"""Read all the specfied directories and hash every picture therein"""
|
|
20
|
-
|
|
21
|
-
hashes = defaultdict(list)
|
|
22
|
-
|
|
23
|
-
# Walk each directory tree
|
|
24
|
-
|
|
25
|
-
for directory in directories:
|
|
26
|
-
print(f'Scanning directory tree {directory}')
|
|
27
|
-
|
|
28
|
-
for root, _, files in os.walk(directory):
|
|
29
|
-
print(f'Scanning directory {root}')
|
|
30
|
-
|
|
31
|
-
for file in files:
|
|
32
|
-
filepath = os.path.join(root, file)
|
|
33
|
-
|
|
34
|
-
fileext = os.path.splitext(file)[1]
|
|
35
|
-
|
|
36
|
-
if fileext.lower() not in ('.jbf', '.ini', '.xml', '.ffs_db'):
|
|
37
|
-
# Calculate the hash and store path, dimensions and file size under the hash entry in the hashes table
|
|
38
|
-
|
|
39
|
-
try:
|
|
40
|
-
with Image.open(filepath) as image:
|
|
41
|
-
hash_value = imagehash.average_hash(image, hash_size=12)
|
|
42
|
-
|
|
43
|
-
size = os.stat(filepath).st_size
|
|
44
|
-
hashes[hash_value].append({'path': filepath, 'width': image.width, 'height': image.height, 'size': size})
|
|
45
|
-
|
|
46
|
-
except PIL.UnidentifiedImageError:
|
|
47
|
-
sys.stderr.write(f'ERROR: Unrecognized format {filepath} (size={size})\n')
|
|
48
|
-
|
|
49
|
-
except OSError:
|
|
50
|
-
sys.stderr.write(f'ERROR: Unable to read {filepath} (size={size})\n')
|
|
51
|
-
|
|
52
|
-
# Return the hash table
|
|
53
|
-
|
|
54
|
-
return hashes
|
|
55
|
-
|
|
56
|
-
################################################################################
|
|
57
|
-
|
|
58
|
-
def main():
|
|
59
|
-
"""Read the hashes and report duplicates in a vaguely civilised way"""
|
|
60
|
-
|
|
61
|
-
parser = argparse.ArgumentParser(description='Search for similar images')
|
|
62
|
-
parser.add_argument('directories', nargs='*', action='store', help='Directories to search')
|
|
63
|
-
|
|
64
|
-
args = parser.parse_args()
|
|
65
|
-
|
|
66
|
-
if not args.directories:
|
|
67
|
-
print('You must be specify at least one directory')
|
|
68
|
-
sys.exit(1)
|
|
69
|
-
|
|
70
|
-
try:
|
|
71
|
-
print('Loading cached data')
|
|
72
|
-
|
|
73
|
-
with open('photodupe.pickle', 'rb') as pickles:
|
|
74
|
-
hashes = pickle.load(pickles)
|
|
75
|
-
except (FileNotFoundError, EOFError):
|
|
76
|
-
print('Scanning directories')
|
|
77
|
-
|
|
78
|
-
hashes = read_image_hashes(args.directories)
|
|
79
|
-
|
|
80
|
-
# Sort the list of hashes so that we can easily find close matches
|
|
81
|
-
|
|
82
|
-
print('Sorting hashes')
|
|
83
|
-
|
|
84
|
-
hash_values = sorted([str(hashval) for hashval in hashes])
|
|
85
|
-
|
|
86
|
-
for hash_value in hash_values:
|
|
87
|
-
if len(hashes[hash_value]) > 1:
|
|
88
|
-
print(hash_value)
|
|
89
|
-
max_len = 0
|
|
90
|
-
min_size = None
|
|
91
|
-
|
|
92
|
-
for entry in hashes[hash_value]:
|
|
93
|
-
max_len = max(max_len, len(entry['path']))
|
|
94
|
-
|
|
95
|
-
if min_size is None:
|
|
96
|
-
min_size = entry['size']
|
|
97
|
-
else:
|
|
98
|
-
min_size = min(min_size, entry['size'])
|
|
99
|
-
|
|
100
|
-
if min_size >= 1024 * 1024:
|
|
101
|
-
size_suffix = 'MiB'
|
|
102
|
-
size_div = 1024*1024
|
|
103
|
-
|
|
104
|
-
elif min_size > 1024:
|
|
105
|
-
size_suffix = 'KiB'
|
|
106
|
-
size_div = 1024
|
|
107
|
-
else:
|
|
108
|
-
size_div = 1
|
|
109
|
-
size_suffix = ''
|
|
110
|
-
|
|
111
|
-
for entry in hashes[hash_value]:
|
|
112
|
-
size = entry['size'] // size_div
|
|
113
|
-
print(f' {entry["path"]:{max_len}} {size:>4} {size_suffix} ({entry["width"]}x{entry["height"]})')
|
|
114
|
-
|
|
115
|
-
with open('photodupe.pickle', 'wb') as pickles:
|
|
116
|
-
pickle.dump(hashes, pickles)
|
|
117
|
-
|
|
118
|
-
################################################################################
|
|
119
|
-
|
|
120
|
-
def photodupe():
|
|
121
|
-
"""Entry point"""
|
|
122
|
-
|
|
123
|
-
try:
|
|
124
|
-
main()
|
|
125
|
-
|
|
126
|
-
except KeyboardInterrupt:
|
|
127
|
-
sys.exit(1)
|
|
128
|
-
|
|
129
|
-
except BrokenPipeError:
|
|
130
|
-
sys.exit(2)
|
|
131
|
-
|
|
132
|
-
################################################################################
|
|
133
|
-
|
|
134
|
-
if __name__ == '__main__':
|
|
135
|
-
photodupe()
|