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