def item_progress(config): """Skips the lookup and query stages in a non-autotagged singleton import. Just shows progress. """ task = None log.info('Importing items:') while True: task = yield task if task.sentinel: continue log.info(displayable_path(task.item.path)) task.set_null_candidates() task.set_choice(action.ASIS)
def read_tasks(config): """A generator yielding all the albums (as ImportTask objects) found in the user-specified list of paths. In the case of a singleton import, yields single-item tasks instead. """ # Look for saved progress. progress = config.resume is not False if progress: resume_dirs = {} for path in config.paths: resume_dir = progress_get(path) if resume_dir: # Either accept immediately or prompt for input to decide. if config.resume: do_resume = True log.warn('Resuming interrupted import of %s' % path) else: do_resume = config.should_resume_func(config, path) if do_resume: resume_dirs[path] = resume_dir else: # Clear progress; we're starting from the top. progress_set(path, None) # Look for saved incremental directories. if config.incremental: incremental_skipped = 0 history_dirs = history_get() for toppath in config.paths: # Check whether the path is to a file. if config.singletons and not os.path.isdir(syspath(toppath)): item = library.Item.from_path(toppath) yield ImportTask.item_task(item) continue # Produce paths under this directory. if progress: resume_dir = resume_dirs.get(toppath) for path, items in autotag.albums_in_dir(toppath, config.ignore): # Skip according to progress. if progress and resume_dir: # We're fast-forwarding to resume a previous tagging. if path == resume_dir: # We've hit the last good path! Turn off the # fast-forwarding. resume_dir = None continue # When incremental, skip paths in the history. if config.incremental and path in history_dirs: log.debug(u'Skipping previously-imported path: %s' % displayable_path(path)) incremental_skipped += 1 continue # Yield all the necessary tasks. if config.singletons: for item in items: yield ImportTask.item_task(item) yield ImportTask.progress_sentinel(toppath, path) else: yield ImportTask(toppath, path, items) # Indicate the directory is finished. yield ImportTask.done_sentinel(toppath) # Show skipped directories. if config.incremental and incremental_skipped: log.info(u'Incremental import: skipped %i directories.' % incremental_skipped)
def apply_choices(config): """A coroutine for applying changes to albums and singletons during the autotag process. """ task = None while True: task = yield task if task.should_skip(): continue items = task.imported_items() # Clear IDs in case the items are being re-tagged. for item in items: item.id = None item.album_id = None # Change metadata. if task.should_write_tags(): if task.is_album: autotag.apply_metadata( task.match.info, task.match.mapping, per_disc_numbering=config.per_disc_numbering ) else: autotag.apply_item_metadata(task.item, task.match.info) plugins.send('import_task_apply', config=config, task=task) # Infer album-level fields. if task.is_album: _infer_album_fields(task) # Find existing item entries that these are replacing (for # re-imports). Old album structures are automatically cleaned up # when the last item is removed. task.replaced_items = defaultdict(list) for item in items: dup_items = config.lib.items(library.MatchQuery('path', item.path)) for dup_item in dup_items: task.replaced_items[item].append(dup_item) log.debug('replacing item %i: %s' % (dup_item.id, displayable_path(item.path))) log.debug('%i of %i items replaced' % (len(task.replaced_items), len(items))) # Find old items that should be replaced as part of a duplicate # resolution. duplicate_items = [] if task.remove_duplicates: if task.is_album: for album in _duplicate_check(config.lib, task): duplicate_items += album.items() else: duplicate_items = _item_duplicate_check(config.lib, task) log.debug('removing %i old duplicated items' % len(duplicate_items)) # Delete duplicate files that are located inside the library # directory. for duplicate_path in [i.path for i in duplicate_items]: if config.lib.directory in util.ancestry(duplicate_path): log.debug(u'deleting replaced duplicate %s' % util.displayable_path(duplicate_path)) util.remove(duplicate_path) util.prune_dirs(os.path.dirname(duplicate_path), config.lib.directory) # Add items -- before path changes -- to the library. We add the # items now (rather than at the end) so that album structures # are in place before calls to destination(). with config.lib.transaction(): # Remove old items. for replaced in task.replaced_items.itervalues(): for item in replaced: config.lib.remove(item) for item in duplicate_items: config.lib.remove(item) # Add new ones. if task.is_album: # Add an album. album = config.lib.add_album(items) task.album_id = album.id else: # Add tracks. for item in items: config.lib.add(item)
def apply_choices(config): """A coroutine for applying changes to albums during the autotag process. """ lib = _reopen_lib(config.lib) task = None while True: task = yield task if task.should_skip(): continue items = [i for i in task.items if i] if task.is_album else [task.item] # Clear IDs in case the items are being re-tagged. for item in items: item.id = None item.album_id = None # Change metadata. if task.should_write_tags(): if task.is_album: autotag.apply_metadata(task.items, task.info) else: autotag.apply_item_metadata(task.item, task.info) # Infer album-level fields. if task.is_album: _infer_album_fields(task) # Find existing item entries that these are replacing (for # re-imports). Old album structures are automatically cleaned up # when the last item is removed. replaced_items = defaultdict(list) for item in items: dup_items = lib.items(library.MatchQuery('path', item.path)) for dup_item in dup_items: replaced_items[item].append(dup_item) log.debug('replacing item %i: %s' % (dup_item.id, displayable_path(item.path))) log.debug('%i of %i items replaced' % (len(replaced_items), len(items))) # Find old items that should be replaced as part of a duplicate # resolution. duplicate_items = [] if task.remove_duplicates: if task.is_album: for album in _duplicate_check(lib, task): duplicate_items += album.items() else: duplicate_items = _item_duplicate_check(lib, task) log.debug('removing %i old duplicated items' % len(duplicate_items)) # Delete duplicate files that are located inside the library # directory. for duplicate_path in [i.path for i in duplicate_items]: if lib.directory in util.ancestry(duplicate_path): log.debug(u'deleting replaced duplicate %s' % util.displayable_path(duplicate_path)) util.soft_remove(duplicate_path) util.prune_dirs(os.path.dirname(duplicate_path), lib.directory) # Move/copy files. task.old_paths = [item.path for item in items] for item in items: if config.copy: # If we're replacing an item, then move rather than # copying. old_path = item.path do_copy = not bool(replaced_items[item]) lib.move(item, do_copy, task.is_album) if not do_copy: # If we moved the item, remove the now-nonexistent # file from old_paths. task.old_paths.remove(old_path) if config.write and task.should_write_tags(): item.write() # Add items to library. We consolidate this at the end to avoid # locking while we do the copying and tag updates. try: # Remove old items. for replaced in replaced_items.itervalues(): for item in replaced: lib.remove(item) for item in duplicate_items: lib.remove(item) # Add new ones. if task.is_album: # Add an album. album = lib.add_album(items) task.album_id = album.id else: # Add tracks. for item in items: lib.add(item) finally: lib.save()
def main(args=None, configfh=None): """Run the main command-line interface for beets.""" # Get the default subcommands. from lib.beets.ui.commands import default_commands # Get default file paths. default_config, default_libpath, default_dir = default_paths() # Read defaults from config file. config = ConfigParser.SafeConfigParser() if configfh: configpath = None elif CONFIG_PATH_VAR in os.environ: configpath = os.path.expanduser(os.environ[CONFIG_PATH_VAR]) else: configpath = default_config if configpath: configpath = util.syspath(configpath) if os.path.exists(util.syspath(configpath)): configfh = open(configpath) else: configfh = None if configfh: config.readfp(configfh) # Add plugin paths. plugpaths = config_val(config, 'beets', 'pluginpath', '') for plugpath in plugpaths.split(':'): sys.path.append(os.path.expanduser(plugpath)) # Load requested plugins. plugnames = config_val(config, 'beets', 'plugins', '') plugins.load_plugins(plugnames.split()) plugins.load_listeners() plugins.send("pluginload") plugins.configure(config) # Construct the root parser. commands = list(default_commands) commands += plugins.commands() parser = SubcommandsOptionParser(subcommands=commands) parser.add_option('-l', '--library', dest='libpath', help='library database file to use') parser.add_option('-d', '--directory', dest='directory', help="destination music directory") parser.add_option('-v', '--verbose', dest='verbose', action='store_true', help='print debugging information') # Parse the command-line! options, subcommand, suboptions, subargs = parser.parse_args(args) # Open library file. libpath = options.libpath or \ config_val(config, 'beets', 'library', default_libpath) directory = options.directory or \ config_val(config, 'beets', 'directory', default_dir) path_formats = _get_path_formats(config) art_filename = \ config_val(config, 'beets', 'art_filename', DEFAULT_ART_FILENAME) lib_timeout = config_val(config, 'beets', 'timeout', DEFAULT_TIMEOUT) replacements = _get_replacements(config) try: lib_timeout = float(lib_timeout) except ValueError: lib_timeout = DEFAULT_TIMEOUT db_path = os.path.expanduser(libpath) try: lib = library.Library(db_path, directory, path_formats, art_filename, lib_timeout, replacements) except sqlite3.OperationalError: raise UserError("database file %s could not be opened" % db_path) # Configure the logger. log = logging.getLogger('beets') if options.verbose: log.setLevel(logging.DEBUG) else: log.setLevel(logging.INFO) log.debug(u'config file: %s' % util.displayable_path(configpath)) log.debug(u'library database: %s' % util.displayable_path(lib.path)) log.debug(u'library directory: %s' % util.displayable_path(lib.directory)) # Invoke the subcommand. try: subcommand.func(lib, config, suboptions, subargs) except UserError, exc: message = exc.args[0] if exc.args else None subcommand.parser.error(message)
def import_files(lib, paths, copy, write, autot, logpath, art, threaded, color, delete, quiet, resume, quiet_fallback, singletons, timid, query, incremental, ignore): """Import the files in the given list of paths, tagging each leaf directory as an album. If copy, then the files are copied into the library folder. If write, then new metadata is written to the files themselves. If not autot, then just import the files without attempting to tag. If logpath is provided, then untaggable albums will be logged there. If art, then attempt to download cover art for each album. If threaded, then accelerate autotagging imports by running them in multiple threads. If color, then ANSI-colorize some terminal output. If delete, then old files are deleted when they are copied. If quiet, then the user is never prompted for input; instead, the tagger just skips anything it is not confident about. resume indicates whether interrupted imports can be resumed and is either a boolean or None. quiet_fallback should be either ASIS or SKIP and indicates what should happen in quiet mode when the recommendation is not strong. """ # Check the user-specified directories. for path in paths: if not singletons and not os.path.isdir(syspath(path)): raise ui.UserError('not a directory: ' + path) elif singletons and not os.path.exists(syspath(path)): raise ui.UserError('no such file: ' + path) # Check parameter consistency. if quiet and timid: raise ui.UserError("can't be both quiet and timid") # Open the log. if logpath: logpath = normpath(logpath) try: logfile = open(syspath(logpath), 'a') except IOError: raise ui.UserError(u"could not open log file for writing: %s" % displayable_path(logpath)) print >>logfile, 'import started', time.asctime() else: logfile = None # Never ask for input in quiet mode. if resume is None and quiet: resume = False try: # Perform the import. importer.run_import( lib = lib, paths = paths, resume = resume, logfile = logfile, color = color, quiet = quiet, quiet_fallback = quiet_fallback, copy = copy, write = write, art = art, delete = delete, threaded = threaded, autot = autot, choose_match_func = choose_match, should_resume_func = should_resume, singletons = singletons, timid = timid, choose_item_func = choose_item, query = query, incremental = incremental, ignore = ignore, resolve_duplicate_func = resolve_duplicate, ) finally: # If we were logging, close the file. if logfile: print >>logfile, '' logfile.close() # Emit event. plugins.send('import', lib=lib, paths=paths)
def update_items(lib, query, album, move, color, pretend): """For all the items matched by the query, update the library to reflect the item's embedded tags. """ items, _ = _do_query(lib, query, album) # Walk through the items and pick up their changes. affected_albums = set() for item in items: # Item deleted? if not os.path.exists(syspath(item.path)): print_(u'X %s - %s' % (item.artist, item.title)) if not pretend: lib.remove(item, True) affected_albums.add(item.album_id) continue # Did the item change since last checked? if item.current_mtime() <= item.mtime: log.debug(u'skipping %s because mtime is up to date (%i)' % (displayable_path(item.path), item.mtime)) continue # Read new data. old_data = dict(item.record) item.read() # Special-case album artist when it matches track artist. (Hacky # but necessary for preserving album-level metadata for non- # autotagged imports.) if not item.albumartist and \ old_data['albumartist'] == old_data['artist'] == item.artist: item.albumartist = old_data['albumartist'] item.dirty['albumartist'] = False # Get and save metadata changes. changes = {} for key in library.ITEM_KEYS_META: if item.dirty[key]: changes[key] = old_data[key], getattr(item, key) if changes: # Something changed. print_(u'* %s - %s' % (item.artist, item.title)) for key, (oldval, newval) in changes.iteritems(): _showdiff(key, oldval, newval, color) # If we're just pretending, then don't move or save. if pretend: continue # Move the item if it's in the library. if move and lib.directory in ancestry(item.path): lib.move(item) lib.store(item) affected_albums.add(item.album_id) elif not pretend: # The file's mtime was different, but there were no changes # to the metadata. Store the new mtime, which is set in the # call to read(), so we don't check this again in the # future. lib.store(item) # Skip album changes while pretending. if pretend: return # Modify affected albums to reflect changes in their items. for album_id in affected_albums: if album_id is None: # Singletons. continue album = lib.get_album(album_id) if not album: # Empty albums have already been removed. log.debug('emptied album %i' % album_id) continue al_items = list(album.items()) # Update album structure to reflect an item in it. for key in library.ALBUM_KEYS_ITEM: setattr(album, key, getattr(al_items[0], key)) # Move album art (and any inconsistent items). if move and lib.directory in ancestry(al_items[0].path): log.debug('moving album %i' % album_id) album.move() lib.save()
def show_change(cur_artist, cur_album, items, info, dist, color=True): """Print out a representation of the changes that will be made if tags are changed from (cur_artist, cur_album, items) to info with distance dist. """ def show_album(artist, album, partial=False): if artist: album_description = u' %s - %s' % (artist, album) elif album: album_description = u' %s' % album else: album_description = u' (unknown album)' # Add a suffix if this is a partial match. if partial: warning = PARTIAL_MATCH_MESSAGE else: warning = None if color and warning: warning = ui.colorize('yellow', warning) out = album_description if warning: out += u' ' + warning print_(out) # Record if the match is partial or not. partial_match = None in items # Identify the album in question. if cur_artist != info.artist or \ (cur_album != info.album and info.album != VARIOUS_ARTISTS): artist_l, artist_r = cur_artist or '', info.artist album_l, album_r = cur_album or '', info.album if artist_r == VARIOUS_ARTISTS: # Hide artists for VA releases. artist_l, artist_r = u'', u'' if color: artist_l, artist_r = ui.colordiff(artist_l, artist_r) album_l, album_r = ui.colordiff(album_l, album_r) print_("Correcting tags from:") show_album(artist_l, album_l) print_("To:") show_album(artist_r, album_r) else: message = u"Tagging: %s - %s" % (info.artist, info.album) if partial_match: warning = PARTIAL_MATCH_MESSAGE if color: warning = ui.colorize('yellow', PARTIAL_MATCH_MESSAGE) message += u' ' + warning print_(message) # Distance/similarity. print_('(Similarity: %s)' % dist_string(dist, color)) # Tracks. missing_tracks = [] for i, (item, track_info) in enumerate(zip(items, info.tracks)): if not item: missing_tracks.append((i, track_info)) continue # Get displayable LHS and RHS values. cur_track = unicode(item.track) new_track = unicode(i+1) cur_title = item.title new_title = track_info.title if item.length and track_info.length: cur_length = ui.human_seconds_short(item.length) new_length = ui.human_seconds_short(track_info.length) if color: cur_length = ui.colorize('red', cur_length) new_length = ui.colorize('red', new_length) # Possibly colorize changes. if color: cur_title, new_title = ui.colordiff(cur_title, new_title) if cur_track != new_track: cur_track = ui.colorize('red', cur_track) new_track = ui.colorize('red', new_track) # Show filename (non-colorized) when title is not set. if not item.title.strip(): cur_title = displayable_path(os.path.basename(item.path)) if cur_title != new_title: lhs, rhs = cur_title, new_title if cur_track != new_track: lhs += u' (%s)' % cur_track rhs += u' (%s)' % new_track print_(u" * %s -> %s" % (lhs, rhs)) else: line = u' * %s' % item.title display = False if cur_track != new_track: display = True line += u' (%s -> %s)' % (cur_track, new_track) if item.length and track_info.length and \ abs(item.length - track_info.length) > 2.0: display = True line += u' (%s -> %s)' % (cur_length, new_length) if display: print_(line) for i, track_info in missing_tracks: line = u' * Missing track: %s (%d)' % (track_info.title, i+1) if color: line = ui.colorize('yellow', line) print_(line)
def main(args=None, configfh=None): """Run the main command-line interface for beets.""" # Get the default subcommands. from lib.beets.ui.commands import default_commands # Get default file paths. default_config, default_libpath, default_dir = default_paths() # Read defaults from config file. config = ConfigParser.SafeConfigParser() if configfh: configpath = None elif CONFIG_PATH_VAR in os.environ: configpath = os.path.expanduser(os.environ[CONFIG_PATH_VAR]) else: configpath = default_config if configpath: configpath = util.syspath(configpath) if os.path.exists(util.syspath(configpath)): configfh = codecs.open(configpath, 'r', encoding='utf-8') else: configfh = None if configfh: config.readfp(configfh) # Add plugin paths. plugpaths = config_val(config, 'beets', 'pluginpath', '') for plugpath in plugpaths.split(':'): sys.path.append(os.path.expanduser(plugpath)) # Load requested plugins. plugnames = config_val(config, 'beets', 'plugins', '') plugins.load_plugins(plugnames.split()) plugins.send("pluginload") plugins.configure(config) # Construct the root parser. commands = list(default_commands) commands += plugins.commands() parser = SubcommandsOptionParser(subcommands=commands) parser.add_option('-l', '--library', dest='libpath', help='library database file to use') parser.add_option('-d', '--directory', dest='directory', help="destination music directory") parser.add_option('-v', '--verbose', dest='verbose', action='store_true', help='print debugging information') # Parse the command-line! options, subcommand, suboptions, subargs = parser.parse_args(args) # Open library file. libpath = options.libpath or \ config_val(config, 'beets', 'library', default_libpath) directory = options.directory or \ config_val(config, 'beets', 'directory', default_dir) path_formats = _get_path_formats(config) art_filename = \ config_val(config, 'beets', 'art_filename', DEFAULT_ART_FILENAME) lib_timeout = config_val(config, 'beets', 'timeout', DEFAULT_TIMEOUT) replacements = _get_replacements(config) try: lib_timeout = float(lib_timeout) except ValueError: lib_timeout = DEFAULT_TIMEOUT db_path = os.path.expanduser(libpath) try: lib = library.Library(db_path, directory, path_formats, art_filename, lib_timeout, replacements) except sqlite3.OperationalError: raise UserError("database file %s could not be opened" % db_path) plugins.send("library_opened", lib=lib) # Configure the logger. log = logging.getLogger('beets') if options.verbose: log.setLevel(logging.DEBUG) else: log.setLevel(logging.INFO) log.debug(u'config file: %s' % util.displayable_path(configpath)) log.debug(u'library database: %s' % util.displayable_path(lib.path)) log.debug(u'library directory: %s' % util.displayable_path(lib.directory)) # Invoke the subcommand. try: subcommand.func(lib, config, suboptions, subargs) except UserError as exc: message = exc.args[0] if exc.args else None subcommand.parser.error(message) except util.HumanReadableException as exc: exc.log(log) sys.exit(1) except IOError as exc: if exc.errno == errno.EPIPE: # "Broken pipe". End silently. pass else: raise
def show_change(cur_artist, cur_album, match, color=True, per_disc_numbering=False): """Print out a representation of the changes that will be made if an album's tags are changed according to `match`, which must be an AlbumMatch object. """ def show_album(artist, album, partial=False): if artist: album_description = u' %s - %s' % (artist, album) elif album: album_description = u' %s' % album else: album_description = u' (unknown album)' # Add a suffix if this is a partial match. if partial: warning = PARTIAL_MATCH_MESSAGE else: warning = None if color and warning: warning = ui.colorize('yellow', warning) out = album_description if warning: out += u' ' + warning print_(out) def format_index(track_info): """Return a string representing the track index of the given TrackInfo object. """ if per_disc_numbering: if match.info.mediums > 1: return u'{0}-{1}'.format(track_info.medium, track_info.medium_index) else: return unicode(track_info.medium_index) else: return unicode(track_info.index) # Identify the album in question. if cur_artist != match.info.artist or \ (cur_album != match.info.album and match.info.album != VARIOUS_ARTISTS): artist_l, artist_r = cur_artist or '', match.info.artist album_l, album_r = cur_album or '', match.info.album if artist_r == VARIOUS_ARTISTS: # Hide artists for VA releases. artist_l, artist_r = u'', u'' if color: artist_l, artist_r = ui.colordiff(artist_l, artist_r) album_l, album_r = ui.colordiff(album_l, album_r) print_("Correcting tags from:") show_album(artist_l, album_l) print_("To:") show_album(artist_r, album_r) else: message = u"Tagging: %s - %s" % (match.info.artist, match.info.album) if match.extra_items or match.extra_tracks: warning = PARTIAL_MATCH_MESSAGE if color: warning = ui.colorize('yellow', PARTIAL_MATCH_MESSAGE) message += u' ' + warning print_(message) # Distance/similarity. print_('(Similarity: %s)' % dist_string(match.distance, color)) # Tracks. pairs = match.mapping.items() pairs.sort(key=lambda (_, track_info): track_info.index) for item, track_info in pairs: # Get displayable LHS and RHS values. cur_track = unicode(item.track) new_track = format_index(track_info) tracks_differ = item.track not in (track_info.index, track_info.medium_index) cur_title = item.title new_title = track_info.title if item.length and track_info.length: cur_length = ui.human_seconds_short(item.length) new_length = ui.human_seconds_short(track_info.length) if color: cur_length = ui.colorize('red', cur_length) new_length = ui.colorize('red', new_length) # Possibly colorize changes. if color: cur_title, new_title = ui.colordiff(cur_title, new_title) cur_track = ui.colorize('red', cur_track) new_track = ui.colorize('red', new_track) # Show filename (non-colorized) when title is not set. if not item.title.strip(): cur_title = displayable_path(os.path.basename(item.path)) if cur_title != new_title: lhs, rhs = cur_title, new_title if tracks_differ: lhs += u' (%s)' % cur_track rhs += u' (%s)' % new_track print_(u" * %s -> %s" % (lhs, rhs)) else: line = u' * %s' % item.title display = False if tracks_differ: display = True line += u' (%s -> %s)' % (cur_track, new_track) if item.length and track_info.length and \ abs(item.length - track_info.length) > 2.0: display = True line += u' (%s vs. %s)' % (cur_length, new_length) if display: print_(line) # Missing and unmatched tracks. for track_info in match.extra_tracks: line = u' * Missing track: {0} ({1})'.format(track_info.title, format_index(track_info)) if color: line = ui.colorize('yellow', line) print_(line) for item in match.extra_items: line = u' * Unmatched track: {0} ({1})'.format(item.title, item.track) if color: line = ui.colorize('yellow', line) print_(line)