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