|
1
|
|
|
#!/usr/bin/env python |
|
2
|
|
|
|
|
3
|
|
|
from __future__ import print_function |
|
4
|
|
|
import os |
|
5
|
|
|
import re |
|
6
|
|
|
import sys |
|
7
|
|
|
from datetime import datetime |
|
8
|
|
|
|
|
9
|
|
|
import click |
|
10
|
|
|
from send2trash import send2trash |
|
11
|
|
|
|
|
12
|
|
|
# Verify that external dependencies are present first, so the user gets a |
|
13
|
|
|
# more user-friendly error instead of an ImportError traceback. |
|
14
|
|
|
from elodie.dependencies import verify_dependencies |
|
15
|
|
|
if not verify_dependencies(): |
|
16
|
|
|
sys.exit(1) |
|
17
|
|
|
|
|
18
|
|
|
from elodie import constants |
|
19
|
|
|
from elodie import geolocation |
|
20
|
|
|
from elodie import log |
|
21
|
|
|
from elodie.compatability import _decode |
|
22
|
|
|
from elodie.filesystem import FileSystem |
|
23
|
|
|
from elodie.localstorage import Db |
|
24
|
|
|
from elodie.media.base import Base, get_all_subclasses |
|
25
|
|
|
from elodie.media.media import Media |
|
26
|
|
|
from elodie.media.text import Text |
|
27
|
|
|
from elodie.media.audio import Audio |
|
28
|
|
|
from elodie.media.photo import Photo |
|
29
|
|
|
from elodie.media.video import Video |
|
30
|
|
|
from elodie.plugins.plugins import Plugins |
|
31
|
|
|
from elodie.result import Result |
|
32
|
|
|
|
|
33
|
|
|
|
|
34
|
|
|
FILESYSTEM = FileSystem() |
|
35
|
|
|
|
|
36
|
|
|
|
|
37
|
|
|
def import_file(_file, destination, album_from_folder, trash, allow_duplicates): |
|
38
|
|
|
|
|
39
|
|
|
_file = _decode(_file) |
|
40
|
|
|
destination = _decode(destination) |
|
41
|
|
|
|
|
42
|
|
|
"""Set file metadata and move it to destination. |
|
43
|
|
|
""" |
|
44
|
|
|
if not os.path.exists(_file): |
|
45
|
|
|
log.warn('Could not find %s' % _file) |
|
46
|
|
|
log.all('{"source":"%s", "error_msg":"Could not find %s"}' % |
|
47
|
|
|
(_file, _file)) |
|
48
|
|
|
return |
|
49
|
|
|
# Check if the source, _file, is a child folder within destination |
|
50
|
|
|
elif destination.startswith(os.path.abspath(os.path.dirname(_file))+os.sep): |
|
51
|
|
|
log.all('{"source": "%s", "destination": "%s", "error_msg": "Source cannot be in destination"}' % ( |
|
52
|
|
|
_file, destination)) |
|
53
|
|
|
return |
|
54
|
|
|
|
|
55
|
|
|
|
|
56
|
|
|
media = Media.get_class_by_file(_file, get_all_subclasses()) |
|
57
|
|
|
if not media: |
|
58
|
|
|
log.warn('Not a supported file (%s)' % _file) |
|
59
|
|
|
log.all('{"source":"%s", "error_msg":"Not a supported file"}' % _file) |
|
60
|
|
|
return |
|
61
|
|
|
|
|
62
|
|
|
if album_from_folder: |
|
63
|
|
|
media.set_album_from_folder() |
|
64
|
|
|
|
|
65
|
|
|
dest_path = FILESYSTEM.process_file(_file, destination, |
|
66
|
|
|
media, allowDuplicate=allow_duplicates, move=False) |
|
67
|
|
|
if dest_path: |
|
68
|
|
|
log.all('%s -> %s' % (_file, dest_path)) |
|
69
|
|
|
if trash: |
|
70
|
|
|
send2trash(_file) |
|
71
|
|
|
|
|
72
|
|
|
return dest_path or None |
|
73
|
|
|
|
|
74
|
|
|
@click.command('batch') |
|
75
|
|
|
@click.option('--debug', default=False, is_flag=True, |
|
76
|
|
|
help='Override the value in constants.py with True.') |
|
77
|
|
|
def _batch(debug): |
|
78
|
|
|
"""Run batch() for all plugins. |
|
79
|
|
|
""" |
|
80
|
|
|
constants.debug = debug |
|
81
|
|
|
plugins = Plugins() |
|
82
|
|
|
plugins.run_batch() |
|
83
|
|
|
|
|
84
|
|
|
|
|
85
|
|
|
@click.command('import') |
|
86
|
|
|
@click.option('--destination', type=click.Path(file_okay=False), |
|
87
|
|
|
required=True, help='Copy imported files into this directory.') |
|
88
|
|
|
@click.option('--source', type=click.Path(file_okay=False), |
|
89
|
|
|
help='Import files from this directory, if specified.') |
|
90
|
|
|
@click.option('--file', type=click.Path(dir_okay=False), |
|
91
|
|
|
help='Import this file, if specified.') |
|
92
|
|
|
@click.option('--album-from-folder', default=False, is_flag=True, |
|
93
|
|
|
help="Use images' folders as their album names.") |
|
94
|
|
|
@click.option('--trash', default=False, is_flag=True, |
|
95
|
|
|
help='After copying files, move the old files to the trash.') |
|
96
|
|
|
@click.option('--allow-duplicates', default=False, is_flag=True, |
|
97
|
|
|
help='Import the file even if it\'s already been imported.') |
|
98
|
|
|
@click.option('--debug', default=False, is_flag=True, |
|
99
|
|
|
help='Override the value in constants.py with True.') |
|
100
|
|
|
@click.argument('paths', nargs=-1, type=click.Path()) |
|
101
|
|
|
def _import(destination, source, file, album_from_folder, trash, allow_duplicates, debug, paths): |
|
102
|
|
|
"""Import files or directories by reading their EXIF and organizing them accordingly. |
|
103
|
|
|
""" |
|
104
|
|
|
constants.debug = debug |
|
105
|
|
|
has_errors = False |
|
106
|
|
|
result = Result() |
|
107
|
|
|
|
|
108
|
|
|
destination = _decode(destination) |
|
109
|
|
|
destination = os.path.abspath(os.path.expanduser(destination)) |
|
110
|
|
|
|
|
111
|
|
|
files = set() |
|
112
|
|
|
paths = set(paths) |
|
113
|
|
|
if source: |
|
114
|
|
|
source = _decode(source) |
|
115
|
|
|
paths.add(source) |
|
116
|
|
|
if file: |
|
117
|
|
|
paths.add(file) |
|
118
|
|
|
for path in paths: |
|
119
|
|
|
path = os.path.expanduser(path) |
|
120
|
|
|
if os.path.isdir(path): |
|
121
|
|
|
files.update(FILESYSTEM.get_all_files(path, None)) |
|
122
|
|
|
else: |
|
123
|
|
|
files.add(path) |
|
124
|
|
|
|
|
125
|
|
|
for current_file in files: |
|
126
|
|
|
dest_path = import_file(current_file, destination, album_from_folder, |
|
127
|
|
|
trash, allow_duplicates) |
|
128
|
|
|
result.append((current_file, dest_path)) |
|
129
|
|
|
has_errors = has_errors is True or not dest_path |
|
130
|
|
|
|
|
131
|
|
|
result.write() |
|
132
|
|
|
|
|
133
|
|
|
if has_errors: |
|
134
|
|
|
sys.exit(1) |
|
135
|
|
|
|
|
136
|
|
|
|
|
137
|
|
|
@click.command('generate-db') |
|
138
|
|
|
@click.option('--source', type=click.Path(file_okay=False), |
|
139
|
|
|
required=True, help='Source of your photo library.') |
|
140
|
|
|
@click.option('--debug', default=False, is_flag=True, |
|
141
|
|
|
help='Override the value in constants.py with True.') |
|
142
|
|
|
def _generate_db(source, debug): |
|
143
|
|
|
"""Regenerate the hash.json database which contains all of the sha256 signatures of media files. The hash.json file is located at ~/.elodie/. |
|
144
|
|
|
""" |
|
145
|
|
|
constants.debug = debug |
|
146
|
|
|
result = Result() |
|
147
|
|
|
source = os.path.abspath(os.path.expanduser(source)) |
|
148
|
|
|
|
|
149
|
|
|
if not os.path.isdir(source): |
|
150
|
|
|
log.error('Source is not a valid directory %s' % source) |
|
151
|
|
|
sys.exit(1) |
|
152
|
|
|
|
|
153
|
|
|
db = Db() |
|
154
|
|
|
db.backup_hash_db() |
|
155
|
|
|
db.reset_hash_db() |
|
156
|
|
|
|
|
157
|
|
|
for current_file in FILESYSTEM.get_all_files(source): |
|
158
|
|
|
result.append((current_file, True)) |
|
159
|
|
|
db.add_hash(db.checksum(current_file), current_file) |
|
160
|
|
|
log.progress() |
|
161
|
|
|
|
|
162
|
|
|
db.update_hash_db() |
|
163
|
|
|
log.progress('', True) |
|
164
|
|
|
result.write() |
|
165
|
|
|
|
|
166
|
|
|
@click.command('verify') |
|
167
|
|
|
@click.option('--debug', default=False, is_flag=True, |
|
168
|
|
|
help='Override the value in constants.py with True.') |
|
169
|
|
|
def _verify(debug): |
|
170
|
|
|
constants.debug = debug |
|
171
|
|
|
result = Result() |
|
172
|
|
|
db = Db() |
|
173
|
|
|
for checksum, file_path in db.all(): |
|
174
|
|
|
if not os.path.isfile(file_path): |
|
175
|
|
|
result.append((file_path, False)) |
|
176
|
|
|
log.progress('x') |
|
177
|
|
|
continue |
|
178
|
|
|
|
|
179
|
|
|
actual_checksum = db.checksum(file_path) |
|
180
|
|
|
if checksum == actual_checksum: |
|
181
|
|
|
result.append((file_path, True)) |
|
182
|
|
|
log.progress() |
|
183
|
|
|
else: |
|
184
|
|
|
result.append((file_path, False)) |
|
185
|
|
|
log.progress('x') |
|
186
|
|
|
|
|
187
|
|
|
log.progress('', True) |
|
188
|
|
|
result.write() |
|
189
|
|
|
|
|
190
|
|
|
|
|
191
|
|
|
def update_location(media, file_path, location_name): |
|
192
|
|
|
"""Update location exif metadata of media. |
|
193
|
|
|
""" |
|
194
|
|
|
location_coords = geolocation.coordinates_by_name(location_name) |
|
195
|
|
|
|
|
196
|
|
|
if location_coords and 'latitude' in location_coords and \ |
|
197
|
|
|
'longitude' in location_coords: |
|
198
|
|
|
location_status = media.set_location(location_coords[ |
|
199
|
|
|
'latitude'], location_coords['longitude']) |
|
200
|
|
|
if not location_status: |
|
201
|
|
|
log.error('Failed to update location') |
|
202
|
|
|
log.all(('{"source":"%s",' % file_path, |
|
203
|
|
|
'"error_msg":"Failed to update location"}')) |
|
204
|
|
|
sys.exit(1) |
|
205
|
|
|
return True |
|
206
|
|
|
|
|
207
|
|
|
|
|
208
|
|
|
def update_time(media, file_path, time_string): |
|
209
|
|
|
"""Update time exif metadata of media. |
|
210
|
|
|
""" |
|
211
|
|
|
time_format = '%Y-%m-%d %H:%M:%S' |
|
212
|
|
|
if re.match(r'^\d{4}-\d{2}-\d{2}$', time_string): |
|
213
|
|
|
time_string = '%s 00:00:00' % time_string |
|
214
|
|
|
elif re.match(r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}\d{2}$', time_string): |
|
215
|
|
|
msg = ('Invalid time format. Use YYYY-mm-dd hh:ii:ss or YYYY-mm-dd') |
|
216
|
|
|
log.error(msg) |
|
217
|
|
|
log.all('{"source":"%s", "error_msg":"%s"}' % (file_path, msg)) |
|
218
|
|
|
sys.exit(1) |
|
219
|
|
|
|
|
220
|
|
|
time = datetime.strptime(time_string, time_format) |
|
221
|
|
|
media.set_date_taken(time) |
|
222
|
|
|
return True |
|
223
|
|
|
|
|
224
|
|
|
|
|
225
|
|
|
@click.command('update') |
|
226
|
|
|
@click.option('--album', help='Update the image album.') |
|
227
|
|
|
@click.option('--location', help=('Update the image location. Location ' |
|
228
|
|
|
'should be the name of a place, like "Las ' |
|
229
|
|
|
'Vegas, NV".')) |
|
230
|
|
|
@click.option('--time', help=('Update the image time. Time should be in ' |
|
231
|
|
|
'YYYY-mm-dd hh:ii:ss or YYYY-mm-dd format.')) |
|
232
|
|
|
@click.option('--title', help='Update the image title.') |
|
233
|
|
|
@click.option('--debug', default=False, is_flag=True, |
|
234
|
|
|
help='Override the value in constants.py with True.') |
|
235
|
|
|
@click.argument('paths', nargs=-1, |
|
236
|
|
|
required=True) |
|
237
|
|
|
def _update(album, location, time, title, paths, debug): |
|
238
|
|
|
"""Update a file's EXIF. Automatically modifies the file's location and file name accordingly. |
|
239
|
|
|
""" |
|
240
|
|
|
constants.debug = debug |
|
241
|
|
|
has_errors = False |
|
242
|
|
|
result = Result() |
|
243
|
|
|
|
|
244
|
|
|
files = set() |
|
245
|
|
|
for path in paths: |
|
246
|
|
|
path = os.path.expanduser(path) |
|
247
|
|
|
if os.path.isdir(path): |
|
248
|
|
|
files.update(FILESYSTEM.get_all_files(path, None)) |
|
249
|
|
|
else: |
|
250
|
|
|
files.add(path) |
|
251
|
|
|
|
|
252
|
|
|
for current_file in files: |
|
253
|
|
|
if not os.path.exists(current_file): |
|
254
|
|
|
has_errors = True |
|
255
|
|
|
result.append((current_file, False)) |
|
256
|
|
|
log.warn('Could not find %s' % current_file) |
|
257
|
|
|
log.all('{"source":"%s", "error_msg":"Could not find %s"}' % |
|
258
|
|
|
(current_file, current_file)) |
|
259
|
|
|
continue |
|
260
|
|
|
|
|
261
|
|
|
current_file = os.path.expanduser(current_file) |
|
262
|
|
|
|
|
263
|
|
|
# The destination folder structure could contain any number of levels |
|
264
|
|
|
# So we calculate that and traverse up the tree. |
|
265
|
|
|
# '/path/to/file/photo.jpg' -> '/path/to/file' -> |
|
266
|
|
|
# ['path','to','file'] -> ['path','to'] -> '/path/to' |
|
267
|
|
|
current_directory = os.path.dirname(current_file) |
|
268
|
|
|
destination_depth = -1 * len(FILESYSTEM.get_folder_path_definition()) |
|
269
|
|
|
destination = os.sep.join( |
|
270
|
|
|
os.path.normpath( |
|
271
|
|
|
current_directory |
|
272
|
|
|
).split(os.sep)[:destination_depth] |
|
273
|
|
|
) |
|
274
|
|
|
|
|
275
|
|
|
media = Media.get_class_by_file(current_file, get_all_subclasses()) |
|
276
|
|
|
if not media: |
|
277
|
|
|
continue |
|
278
|
|
|
|
|
279
|
|
|
updated = False |
|
280
|
|
|
if location: |
|
281
|
|
|
update_location(media, current_file, location) |
|
282
|
|
|
updated = True |
|
283
|
|
|
if time: |
|
284
|
|
|
update_time(media, current_file, time) |
|
285
|
|
|
updated = True |
|
286
|
|
|
if album: |
|
287
|
|
|
media.set_album(album) |
|
288
|
|
|
updated = True |
|
289
|
|
|
|
|
290
|
|
|
# Updating a title can be problematic when doing it 2+ times on a file. |
|
291
|
|
|
# You would end up with img_001.jpg -> img_001-first-title.jpg -> |
|
292
|
|
|
# img_001-first-title-second-title.jpg. |
|
293
|
|
|
# To resolve that we have to track the prior title (if there was one. |
|
294
|
|
|
# Then we massage the updated_media's metadata['base_name'] to remove |
|
295
|
|
|
# the old title. |
|
296
|
|
|
# Since FileSystem.get_file_name() relies on base_name it will properly |
|
297
|
|
|
# rename the file by updating the title instead of appending it. |
|
298
|
|
|
remove_old_title_from_name = False |
|
299
|
|
|
if title: |
|
300
|
|
|
# We call get_metadata() to cache it before making any changes |
|
301
|
|
|
metadata = media.get_metadata() |
|
302
|
|
|
title_update_status = media.set_title(title) |
|
303
|
|
|
original_title = metadata['title'] |
|
304
|
|
|
if title_update_status and original_title: |
|
305
|
|
|
# @TODO: We should move this to a shared method since |
|
306
|
|
|
# FileSystem.get_file_name() does it too. |
|
307
|
|
|
original_title = re.sub(r'\W+', '-', original_title.lower()) |
|
308
|
|
|
original_base_name = metadata['base_name'] |
|
309
|
|
|
remove_old_title_from_name = True |
|
310
|
|
|
updated = True |
|
311
|
|
|
|
|
312
|
|
|
if updated: |
|
313
|
|
|
updated_media = Media.get_class_by_file(current_file, |
|
314
|
|
|
get_all_subclasses()) |
|
315
|
|
|
# See comments above on why we have to do this when titles |
|
316
|
|
|
# get updated. |
|
317
|
|
|
if remove_old_title_from_name and len(original_title) > 0: |
|
|
|
|
|
|
318
|
|
|
updated_media.get_metadata() |
|
319
|
|
|
updated_media.set_metadata_basename( |
|
320
|
|
|
original_base_name.replace('-%s' % original_title, '')) |
|
|
|
|
|
|
321
|
|
|
|
|
322
|
|
|
dest_path = FILESYSTEM.process_file(current_file, destination, |
|
323
|
|
|
updated_media, move=True, allowDuplicate=True) |
|
324
|
|
|
log.info(u'%s -> %s' % (current_file, dest_path)) |
|
325
|
|
|
log.all('{"source":"%s", "destination":"%s"}' % (current_file, |
|
326
|
|
|
dest_path)) |
|
327
|
|
|
# If the folder we moved the file out of or its parent are empty |
|
328
|
|
|
# we delete it. |
|
329
|
|
|
FILESYSTEM.delete_directory_if_empty(os.path.dirname(current_file)) |
|
330
|
|
|
FILESYSTEM.delete_directory_if_empty( |
|
331
|
|
|
os.path.dirname(os.path.dirname(current_file))) |
|
332
|
|
|
result.append((current_file, dest_path)) |
|
333
|
|
|
# Trip has_errors to False if it's already False or dest_path is. |
|
334
|
|
|
has_errors = has_errors is True or not dest_path |
|
335
|
|
|
else: |
|
336
|
|
|
has_errors = False |
|
337
|
|
|
result.append((current_file, False)) |
|
338
|
|
|
|
|
339
|
|
|
result.write() |
|
340
|
|
|
|
|
341
|
|
|
if has_errors: |
|
342
|
|
|
sys.exit(1) |
|
343
|
|
|
|
|
344
|
|
|
|
|
345
|
|
|
@click.group() |
|
346
|
|
|
def main(): |
|
347
|
|
|
pass |
|
348
|
|
|
|
|
349
|
|
|
|
|
350
|
|
|
main.add_command(_import) |
|
351
|
|
|
main.add_command(_update) |
|
352
|
|
|
main.add_command(_generate_db) |
|
353
|
|
|
main.add_command(_verify) |
|
354
|
|
|
main.add_command(_batch) |
|
355
|
|
|
|
|
356
|
|
|
|
|
357
|
|
|
if __name__ == '__main__': |
|
358
|
|
|
main() |
|
359
|
|
|
|