Passed
Push — master ( 75e659...d8cee1 )
by Jaisen
01:58
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
    log.progress('', True)
178
    result.write()
179
180
@click.command('verify')
181
@click.option('--debug', default=False, is_flag=True,
182
              help='Override the value in constants.py with True.')
183
def _verify(debug):
184
    constants.debug = debug
185
    result = Result()
186
    db = Db()
187
    for checksum, file_path in db.all():
188
        if not os.path.isfile(file_path):
189
            result.append((file_path, False))
190
            log.progress('x')
191
            continue
192
193
        actual_checksum = db.checksum(file_path)
194
        if checksum == actual_checksum:
195
            result.append((file_path, True))
196
            log.progress()
197
        else:
198
            result.append((file_path, False))
199
            log.progress('x')
200
201
    log.progress('', True)
202
    result.write()
203
204
205
def update_location(media, file_path, location_name):
206
    """Update location exif metadata of media.
207
    """
208
    location_coords = geolocation.coordinates_by_name(location_name)
209
210
    if location_coords and 'latitude' in location_coords and \
211
            'longitude' in location_coords:
212
        location_status = media.set_location(location_coords[
213
            'latitude'], location_coords['longitude'])
214
        if not location_status:
215
            log.error('Failed to update location')
216
            log.all(('{"source":"%s",' % file_path,
217
                       '"error_msg":"Failed to update location"}'))
218
            sys.exit(1)
219
    return True
220
221
222
def update_time(media, file_path, time_string):
223
    """Update time exif metadata of media.
224
    """
225
    time_format = '%Y-%m-%d %H:%M:%S'
226
    if re.match(r'^\d{4}-\d{2}-\d{2}$', time_string):
227
        time_string = '%s 00:00:00' % time_string
228
    elif re.match(r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}\d{2}$', time_string):
229
        msg = ('Invalid time format. Use YYYY-mm-dd hh:ii:ss or YYYY-mm-dd')
230
        log.error(msg)
231
        log.all('{"source":"%s", "error_msg":"%s"}' % (file_path, msg))
232
        sys.exit(1)
233
234
    time = datetime.strptime(time_string, time_format)
235
    media.set_date_taken(time)
236
    return True
237
238
239
@click.command('update')
240
@click.option('--album', help='Update the image album.')
241
@click.option('--location', help=('Update the image location. Location '
242
                                  'should be the name of a place, like "Las '
243
                                  'Vegas, NV".'))
244
@click.option('--time', help=('Update the image time. Time should be in '
245
                              'YYYY-mm-dd hh:ii:ss or YYYY-mm-dd format.'))
246
@click.option('--title', help='Update the image title.')
247
@click.option('--debug', default=False, is_flag=True,
248
              help='Override the value in constants.py with True.')
249
@click.argument('paths', nargs=-1,
250
                required=True)
251
def _update(album, location, time, title, paths, debug):
252
    """Update a file's EXIF. Automatically modifies the file's location and file name accordingly.
253
    """
254
    constants.debug = debug
255
    has_errors = False
256
    result = Result()
257
258
    files = set()
259
    for path in paths:
260
        path = os.path.expanduser(path)
261
        if os.path.isdir(path):
262
            files.update(FILESYSTEM.get_all_files(path, None))
263
        else:
264
            files.add(path)
265
266
    for current_file in files:
267
        if not os.path.exists(current_file):
268
            has_errors = True
269
            result.append((current_file, False))
270
            log.warn('Could not find %s' % current_file)
271
            log.all('{"source":"%s", "error_msg":"Could not find %s"}' %
272
                      (current_file, current_file))
273
            continue
274
275
        current_file = os.path.expanduser(current_file)
276
277
        # The destination folder structure could contain any number of levels
278
        #  So we calculate that and traverse up the tree.
279
        # '/path/to/file/photo.jpg' -> '/path/to/file' ->
280
        #  ['path','to','file'] -> ['path','to'] -> '/path/to'
281
        current_directory = os.path.dirname(current_file)
282
        destination_depth = -1 * len(FILESYSTEM.get_folder_path_definition())
283
        destination = os.sep.join(
284
                          os.path.normpath(
285
                              current_directory
286
                          ).split(os.sep)[:destination_depth]
287
                      )
288
289
        media = Media.get_class_by_file(current_file, get_all_subclasses())
290
        if not media:
291
            continue
292
293
        updated = False
294
        if location:
295
            update_location(media, current_file, location)
296
            updated = True
297
        if time:
298
            update_time(media, current_file, time)
299
            updated = True
300
        if album:
301
            media.set_album(album)
302
            updated = True
303
304
        # Updating a title can be problematic when doing it 2+ times on a file.
305
        # You would end up with img_001.jpg -> img_001-first-title.jpg ->
306
        # img_001-first-title-second-title.jpg.
307
        # To resolve that we have to track the prior title (if there was one.
308
        # Then we massage the updated_media's metadata['base_name'] to remove
309
        # the old title.
310
        # Since FileSystem.get_file_name() relies on base_name it will properly
311
        #  rename the file by updating the title instead of appending it.
312
        remove_old_title_from_name = False
313
        if title:
314
            # We call get_metadata() to cache it before making any changes
315
            metadata = media.get_metadata()
316
            title_update_status = media.set_title(title)
317
            original_title = metadata['title']
318
            if title_update_status and original_title:
319
                # @TODO: We should move this to a shared method since
320
                # FileSystem.get_file_name() does it too.
321
                original_title = re.sub(r'\W+', '-', original_title.lower())
322
                original_base_name = metadata['base_name']
323
                remove_old_title_from_name = True
324
            updated = True
325
326
        if updated:
327
            updated_media = Media.get_class_by_file(current_file,
328
                                                    get_all_subclasses())
329
            # See comments above on why we have to do this when titles
330
            # get updated.
331
            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...
332
                updated_media.get_metadata()
333
                updated_media.set_metadata_basename(
334
                    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...
335
336
            dest_path = FILESYSTEM.process_file(current_file, destination,
337
                updated_media, move=True, allowDuplicate=True)
338
            log.info(u'%s -> %s' % (current_file, dest_path))
339
            log.all('{"source":"%s", "destination":"%s"}' % (current_file,
340
                                                               dest_path))
341
            # If the folder we moved the file out of or its parent are empty
342
            # we delete it.
343
            FILESYSTEM.delete_directory_if_empty(os.path.dirname(current_file))
344
            FILESYSTEM.delete_directory_if_empty(
345
                os.path.dirname(os.path.dirname(current_file)))
346
            result.append((current_file, dest_path))
347
            # Trip has_errors to False if it's already False or dest_path is.
348
            has_errors = has_errors is True or not dest_path
349
        else:
350
            has_errors = False
351
            result.append((current_file, False))
352
353
    result.write()
354
    
355
    if has_errors:
356
        sys.exit(1)
357
358
359
@click.group()
360
def main():
361
    pass
362
363
364
main.add_command(_import)
365
main.add_command(_update)
366
main.add_command(_generate_db)
367
main.add_command(_verify)
368
main.add_command(_batch)
369
370
371
if __name__ == '__main__':
372
    #Initialize ExifTool Subprocess
373
    exiftool_addedargs = [
374
       u'-config',
375
        u'"{}"'.format(constants.exiftool_config)
376
    ]
377
    with ExifTool(executable_=get_exiftool(), addedargs=exiftool_addedargs) as et:
378
        main()
379