Passed
Pull Request — master (#443)
by Jaisen
05:38
created

elodie.py (2 issues)

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