#!/usr/bin/env python from __future__ import print_function import os import re import sys from datetime import datetime import click from send2trash import send2trash # Verify that external dependencies are present first, so the user gets a # more user-friendly error instead of an ImportError traceback. from elodie.dependencies import verify_dependencies if not verify_dependencies(): sys.exit(1) from elodie import constants from elodie import geolocation from elodie import log from elodie.filesystem import FileSystem from elodie.localstorage import Db from elodie.media.base import Base, get_all_subclasses from elodie.media.media import Media from elodie.media.text import Text from elodie.media.audio import Audio from elodie.media.photo import Photo from elodie.media.video import Video from elodie.result import Result FILESYSTEM = FileSystem() def import_file(_file, destination, album_from_folder, trash, allow_duplicates): """Set file metadata and move it to destination. """ if not os.path.exists(_file): log.warn('Could not find %s' % _file) print('{"source":"%s", "error_msg":"Could not find %s"}' % \ (_file, _file)) return # Check if the source, _file, is a child folder within destination elif destination.startswith(os.path.dirname(_file)): print('{"source": "%s", "destination": "%s", "error_msg": "Source cannot be in destination"}' % (_file, destination)) return media = Media.get_class_by_file(_file, [Text, Audio, Photo, Video]) if not media: log.warn('Not a supported file (%s)' % _file) print('{"source":"%s", "error_msg":"Not a supported file"}' % _file) return if album_from_folder: media.set_album_from_folder() dest_path = FILESYSTEM.process_file(_file, destination, media, allowDuplicate=allow_duplicates, move=False) if dest_path: print('%s -> %s' % (_file, dest_path)) if trash: send2trash(_file) return dest_path or None @click.command('import') @click.option('--destination', type=click.Path(file_okay=False), required=True, help='Copy imported files into this directory.') @click.option('--source', type=click.Path(file_okay=False), help='Import files from this directory, if specified.') @click.option('--file', type=click.Path(dir_okay=False), help='Import this file, if specified.') @click.option('--album-from-folder', default=False, is_flag=True, help="Use images' folders as their album names.") @click.option('--trash', default=False, is_flag=True, help='After copying files, move the old files to the trash.') @click.option('--allow-duplicates', default=False, is_flag=True, help='Import the file even if it\'s already been imported.') @click.argument('paths', nargs=-1, type=click.Path()) def _import(destination, source, file, album_from_folder, trash, paths, allow_duplicates): """Import files or directories by reading their EXIF and organizing them accordingly. """ result = Result() destination = os.path.abspath(os.path.expanduser(destination)) files = set() paths = set(paths) if source: paths.add(source) if file: paths.add(file) for path in paths: path = os.path.expanduser(path) if os.path.isdir(path): files.update(FILESYSTEM.get_all_files(path, None)) else: files.add(path) for current_file in files: dest_path = import_file(current_file, destination, album_from_folder, trash, allow_duplicates) result.append((current_file, dest_path)) result.write() @click.command('generate-db') @click.option('--source', type=click.Path(file_okay=False), required=True, help='Source of your photo library.') def _generate_db(source): """Regenerate the hash.json database which contains all of the sha1 signatures of media files. """ result = Result() source = os.path.abspath(os.path.expanduser(source)) extensions = set() all_files = set() valid_files = set() if not os.path.isdir(source): log.error('Source is not a valid directory %s' % source) sys.exit(1) subclasses = get_all_subclasses(Base) for cls in subclasses: extensions.update(cls.extensions) all_files.update(FILESYSTEM.get_all_files(source, None)) db = Db() db.backup_hash_db() db.reset_hash_db() for current_file in all_files: if os.path.splitext(current_file)[1][1:].lower() not in extensions: log.info('Skipping invalid file %s' % current_file) result.append((current_file, False)) continue result.append((current_file, True)) db.add_hash(db.checksum(current_file), current_file) db.update_hash_db() result.write() @click.command('verify') def _verify(): result = Result() db = Db() for checksum, file_path in db.all(): if not os.path.isfile(file_path): result.append((file_path, False)) continue actual_checksum = db.checksum(file_path) if checksum == actual_checksum: result.append((file_path, True)) else: result.append((file_path, False)) result.write() def update_location(media, file_path, location_name): """Update location exif metadata of media. """ location_coords = geolocation.coordinates_by_name(location_name) if location_coords and 'latitude' in location_coords and \ 'longitude' in location_coords: location_status = media.set_location(location_coords[ 'latitude'], location_coords['longitude']) if not location_status: log.error('Failed to update location') print(('{"source":"%s",' % file_path, '"error_msg":"Failed to update location"}')) sys.exit(1) return True def update_time(media, file_path, time_string): """Update time exif metadata of media. """ time_format = '%Y-%m-%d %H:%M:%S' if re.match(r'^\d{4}-\d{2}-\d{2}$', time_string): time_string = '%s 00:00:00' % time_string elif re.match(r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}\d{2}$', time_string): msg = ('Invalid time format. Use YYYY-mm-dd hh:ii:ss or YYYY-mm-dd') log.error(msg) print('{"source":"%s", "error_msg":"%s"}' % (file_path, msg)) sys.exit(1) time = datetime.strptime(time_string, time_format) media.set_date_taken(time) return True @click.command('update') @click.option('--album', help='Update the image album.') @click.option('--location', help=('Update the image location. Location ' 'should be the name of a place, like "Las ' 'Vegas, NV".')) @click.option('--time', help=('Update the image time. Time should be in ' 'YYYY-mm-dd hh:ii:ss or YYYY-mm-dd format.')) @click.option('--title', help='Update the image title.') @click.argument('files', nargs=-1, type=click.Path(dir_okay=False), required=True) def _update(album, location, time, title, files): """Update a file's EXIF. Automatically modifies the file's location and file name accordingly. """ result = Result() for current_file in files: if not os.path.exists(current_file): if constants.debug: print('Could not find %s' % current_file) print('{"source":"%s", "error_msg":"Could not find %s"}' % \ (current_file, current_file)) continue current_file = os.path.expanduser(current_file) destination = os.path.expanduser(os.path.dirname(os.path.dirname( os.path.dirname(current_file)))) media = Media.get_class_by_file(current_file, [Text, Audio, Photo, Video]) if not media: continue updated = False if location: update_location(media, current_file, location) updated = True if time: update_time(media, current_file, time) updated = True if album: media.set_album(album) updated = True # Updating a title can be problematic when doing it 2+ times on a file. # You would end up with img_001.jpg -> img_001-first-title.jpg -> # img_001-first-title-second-title.jpg. # To resolve that we have to track the prior title (if there was one. # Then we massage the updated_media's metadata['base_name'] to remove # the old title. # Since FileSystem.get_file_name() relies on base_name it will properly # rename the file by updating the title instead of appending it. remove_old_title_from_name = False if title: # We call get_metadata() to cache it before making any changes metadata = media.get_metadata() title_update_status = media.set_title(title) original_title = metadata['title'] if title_update_status and original_title: # @TODO: We should move this to a shared method since # FileSystem.get_file_name() does it too. original_title = re.sub(r'\W+', '-', original_title.lower()) original_base_name = metadata['base_name'] remove_old_title_from_name = True updated = True if updated: updated_media = Media.get_class_by_file(current_file, [Text, Audio, Photo, Video]) # See comments above on why we have to do this when titles # get updated. if remove_old_title_from_name and len(original_title) > 0: updated_media.get_metadata() updated_media.set_metadata_basename( original_base_name.replace('-%s' % original_title, '')) dest_path = FILESYSTEM.process_file(current_file, destination, updated_media, move=True, allowDuplicate=True) log.info(u'%s -> %s' % (current_file, dest_path)) print('{"source":"%s", "destination":"%s"}' % (current_file, dest_path)) # If the folder we moved the file out of or its parent are empty # we delete it. FILESYSTEM.delete_directory_if_empty(os.path.dirname(current_file)) FILESYSTEM.delete_directory_if_empty( os.path.dirname(os.path.dirname(current_file))) result.append((current_file, dest_path)) else: result.append((current_file, False)) result.write() @click.group() def main(): pass main.add_command(_import) main.add_command(_update) main.add_command(_generate_db) main.add_command(_verify) if __name__ == '__main__': main()