Passed
Pull Request — master (#303)
by
unknown
01:22
created

elodie.elodie.main()   A

Complexity

Conditions 1

Size

Total Lines 3
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

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