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