diff --git a/beets/importer.py b/beets/importer.py index 91d4b9f40..120cf903b 100644 --- a/beets/importer.py +++ b/beets/importer.py @@ -26,7 +26,6 @@ from beets import autotag from beets import library import beets.autotag.art from beets import plugins -from beets.ui import commands CHOICE_SKIP = 'CHOICE_SKIP' CHOICE_ASIS = 'CHOICE_ASIS' @@ -115,6 +114,21 @@ def progress_get(toppath): return state[PROGRESS_KEY].get(toppath) +# The configuration structure. + +class ImportConfig(object): + """Contains all the settings used during an import session. Should + be used in a "write-once" way -- everything is set up initially and + then never touched again. + """ + __slots__ = ['lib', 'paths', 'resume', 'logfile', 'color', 'quiet', + 'quiet_fallback', 'copy', 'write', 'art', 'delete', + 'choose_match_func'] + def __init__(self, **kwargs): + for slot in self.__slots__: + setattr(self, slot, kwargs[slot]) + + # The importer task class. class ImportTask(object): @@ -216,14 +230,14 @@ class ImportTask(object): # Core autotagger pipeline stages. -def read_albums(paths, resume): +def read_albums(config): """A generator yielding all the albums (as ImportTask objects) found in the user-specified list of paths. `progress` specifies whether the resuming feature should be used. It may be True (resume if possible), False (never resume), or None (ask). """ # Use absolute paths. - paths = [library._normpath(path) for path in paths] + paths = [library._normpath(path) for path in config.paths] # Check the user-specified directories. for path in paths: @@ -231,7 +245,7 @@ def read_albums(paths, resume): raise ui.UserError('not a directory: ' + path) # Look for saved progress. - progress = resume is not False + progress = config.resume is not False if progress: resume_dirs = {} for path in paths: @@ -239,7 +253,7 @@ def read_albums(paths, resume): if resume_dir: # Either accept immediately or prompt for input to decide. - if resume: + if config.resume: do_resume = True ui.print_('Resuming interrupted import of %s' % path) else: @@ -272,7 +286,7 @@ def read_albums(paths, resume): # Indicate the directory is finished. yield ImportTask.done_sentinel(toppath) -def initial_lookup(): +def initial_lookup(config): """A coroutine for performing the initial MusicBrainz lookup for an album. It accepts lists of Items and yields (items, cur_artist, cur_album, candidates, rec) tuples. If no match @@ -291,13 +305,13 @@ def initial_lookup(): task.set_null_match() task = yield task -def user_query(lib, logfile, color, quiet, quiet_fallback): +def user_query(config): """A coroutine for interfacing with the user about the tagging process. lib is the Library to import into and logfile may be a file-like object for logging the import process. The coroutine accepts and yields ImportTask objects. """ - lib = _reopen_lib(lib) + lib = _reopen_lib(config.lib) first = True task = None while True: @@ -313,16 +327,14 @@ def user_query(lib, logfile, color, quiet, quiet_fallback): print_(task.path) # Ask the user for a choice. - choice = commands.choose_match(task.path, task.items, task.cur_artist, - task.cur_album, task.candidates, - task.rec, color, quiet, quiet_fallback) + choice = config.choose_match_func(task, config) task.set_choice(choice) # Log certain choices. if choice is CHOICE_ASIS: - tag_log(logfile, 'asis', task.path) + tag_log(config.logfile, 'asis', task.path) elif choice is CHOICE_SKIP: - tag_log(logfile, 'skip', task.path) + tag_log(config.logfile, 'skip', task.path) # Check for duplicates if we have a match. if choice == CHOICE_ASIS or isinstance(choice, tuple): @@ -333,35 +345,35 @@ def user_query(lib, logfile, color, quiet, quiet_fallback): artist = task.info['artist'] album = task.info['album'] if _duplicate_check(lib, artist, album): - tag_log(logfile, 'duplicate', task.path) + tag_log(config.logfile, 'duplicate', task.path) print_("This album is already in the library!") task.set_choice(CHOICE_SKIP) -def apply_choices(lib, copy, write, art, delete, progress): +def apply_choices(config): """A coroutine for applying changes to albums during the autotag process. The parameters to the generator control the behavior of the import. The coroutine accepts ImportTask objects and yields nothing. """ - lib = _reopen_lib(lib) + lib = _reopen_lib(config.lib) while True: task = yield # Don't do anything if we're skipping the album or we're done. if task.choice_flag == CHOICE_SKIP or task.sentinel: - if progress: + if config.resume is not False: task.save_progress() continue # Change metadata, move, and copy. if task.should_write_tags(): autotag.apply_metadata(task.items, task.info) - if copy and delete: + if config.copy and config.delete: old_paths = [os.path.realpath(item.path) for item in task.items] for item in task.items: - if copy: + if config.copy: item.move(lib, True, task.should_create_album()) - if write and task.should_write_tags(): + if config.write and task.should_write_tags(): item.write() # Add items to library. We consolidate this at the end to avoid @@ -376,7 +388,7 @@ def apply_choices(lib, copy, write, art, delete, progress): lib.add(item) # Get album art if requested. - if art and task.should_fetch_art(): + if config.art and task.should_fetch_art(): artpath = beets.autotag.art.art_for_album(task.info) if artpath: albuminfo.set_art(artpath) @@ -392,7 +404,7 @@ def apply_choices(lib, copy, write, art, delete, progress): plugins.send('item_imported', lib=lib, item=item) # Finally, delete old files. - if copy and delete: + if config.copy and config.delete: new_paths = [os.path.realpath(item.path) for item in task.items] for old_path in old_paths: # Only delete files that were actually moved. @@ -400,38 +412,38 @@ def apply_choices(lib, copy, write, art, delete, progress): os.remove(library._syspath(old_path)) # Update progress. - if progress: + if config.resume is not False: task.save_progress() # Non-autotagged import (always sequential). #TODO probably no longer necessary; use the same machinery? -def simple_import(lib, paths, copy, delete, resume): +def simple_import(config): """Add files from the paths to the library without changing any tags. """ - for task in read_albums(paths, resume): + for task in read_albums(config): if task.sentinel: task.save_progress() continue - if copy: - if delete: + if config.copy: + if config.delete: old_paths = [os.path.realpath(item.path) for item in task.items] for item in task.items: - item.move(lib, True, True) + item.move(config.lib, True, True) - album = lib.add_album(task.items, True) - lib.save() + album = config.lib.add_album(task.items, True) + config.lib.save() # Announce that we added an album. plugins.send('album_imported', album=album) - if resume is not False: + if config.resume is not False: task.save_progress() - if copy and delete: + if config.copy and config.delete: new_paths = [os.path.realpath(item.path) for item in task.items] for old_path in old_paths: # Only delete files that were actually moved. diff --git a/beets/ui/commands.py b/beets/ui/commands.py index b3d66f79b..e0af1d28b 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -227,31 +227,32 @@ def manual_search(): album = raw_input('Album: ').decode(sys.stdin.encoding) return artist.strip(), album.strip() -def choose_match(path, items, cur_artist, cur_album, candidates, - rec, color, quiet, quiet_fallback): +def choose_match(task, config): """Given an initial autotagging of items, go through an interactive dance with the user to ask for a choice of metadata. Returns an (info, items) pair, CHOICE_ASIS, or CHOICE_SKIP. """ - if quiet: + if config.quiet: # No input; just make a decision. - if rec == autotag.RECOMMEND_STRONG: - dist, items, info = candidates[0] - show_change(cur_artist, cur_album, items, info, dist, color) + if task.rec == autotag.RECOMMEND_STRONG: + dist, items, info = task.candidates[0] + show_change(task.cur_artist, task.cur_album, items, info, dist, + config.color) return info, items else: - if quiet_fallback == importer.CHOICE_SKIP: + if config.quiet_fallback == importer.CHOICE_SKIP: print_('Skipping.') - elif quiet_fallback == importer.CHOICE_ASIS: + elif config.quiet_fallback == importer.CHOICE_ASIS: print_('Importing as-is.') else: assert(False) - return quiet_fallback + return config.quiet_fallback # Loop until we have a choice. while True: # Ask for a choice from the user. - choice = choose_candidate(cur_artist, cur_album, candidates, rec, color) + choice = choose_candidate(task.cur_artist, task.cur_album, + task.candidates, task.rec, config.color) # Choose which tags to use. if choice in (importer.CHOICE_SKIP, importer.CHOICE_ASIS, @@ -301,15 +302,31 @@ def import_files(lib, paths, copy, write, autot, logpath, art, threaded, # Never ask for input in quiet mode. if resume is None and quiet: resume = False + + # Set up import configuration. + config = importer.ImportConfig( + paths = paths, + resume = resume, + lib = lib, + logfile = logfile, + color = color, + quiet = quiet, + quiet_fallback = quiet_fallback, + copy = copy, + write = write, + art = art, + delete = delete, + choose_match_func = choose_match, + ) # Perform the import. if autot: # Autotag. Set up the pipeline. pl = pipeline.Pipeline([ - importer.read_albums(paths, resume), - importer.initial_lookup(), - importer.user_query(lib, logfile, color, quiet, quiet_fallback), - importer.apply_choices(lib, copy, write, art, delete, resume is not False), + importer.read_albums(config), + importer.initial_lookup(config), + importer.user_query(config), + importer.apply_choices(config), ]) # Run the pipeline. @@ -323,7 +340,7 @@ def import_files(lib, paths, copy, write, autot, logpath, art, threaded, pass else: # Simple import without autotagging. Always sequential. - importer.simple_import(lib, paths, copy, delete, resume) + importer.simple_import(config) # If we were logging, close the file. if logfile: diff --git a/test/_common.py b/test/_common.py index 604999c37..b04d98a27 100644 --- a/test/_common.py +++ b/test/_common.py @@ -6,6 +6,7 @@ import os # Mangle the search path to include the beets sources. sys.path.insert(0, '..') import beets.library +from beets import importer # Dummy item creation. def item(): return beets.library.Item({ @@ -38,6 +39,25 @@ def item(): return beets.library.Item({ 'album_id': None, }) +# Dummy import stuff. +def iconfig(lib, **kwargs): + config = importer.ImportConfig( + lib = lib, + paths = None, + resume = False, + logfile = None, + color = False, + quiet = True, + quiet_fallback = importer.CHOICE_SKIP, + copy = True, + write = False, + art = False, + delete = False, + choose_match_func = lambda x, y: importer.CHOICE_SKIP, + ) + for k, v in kwargs.items(): + setattr(config, k, v) + return config # Mock timing. diff --git a/test/test_importer.py b/test/test_importer.py index d6add3c01..2b710cc02 100644 --- a/test/test_importer.py +++ b/test/test_importer.py @@ -64,22 +64,19 @@ class ImportApplyTest(unittest.TestCase, _common.ExtraAsserts): coro.send(task) def test_apply_no_delete(self): - coro = importer.apply_choices(self.lib, True, False, False, - False, False) + coro = importer.apply_choices(_common.iconfig(self.lib, delete=False)) coro.next() # Prime coroutine. self._call_apply(coro, [self.i], self.info) self.assertExists(self.srcpath) def test_apply_with_delete(self): - coro = importer.apply_choices(self.lib, True, False, False, - True, False) + coro = importer.apply_choices(_common.iconfig(self.lib, delete=True)) coro.next() # Prime coroutine. self._call_apply(coro, [self.i], self.info) self.assertNotExists(self.srcpath) def test_apply_asis_uses_album_path(self): - coro = importer.apply_choices(self.lib, True, False, False, - False, False) + coro = importer.apply_choices(_common.iconfig(self.lib)) coro.next() # Prime coroutine. self._call_apply_choice(coro, [self.i], importer.CHOICE_ASIS) self.assertExists( @@ -87,8 +84,7 @@ class ImportApplyTest(unittest.TestCase, _common.ExtraAsserts): ) def test_apply_match_uses_album_path(self): - coro = importer.apply_choices(self.lib, True, False, False, - False, False) + coro = importer.apply_choices(_common.iconfig(self.lib)) coro.next() # Prime coroutine. self._call_apply(coro, [self.i], self.info) self.assertExists( @@ -96,8 +92,7 @@ class ImportApplyTest(unittest.TestCase, _common.ExtraAsserts): ) def test_apply_as_tracks_uses_singleton_path(self): - coro = importer.apply_choices(self.lib, True, False, False, - False, False) + coro = importer.apply_choices(_common.iconfig(self.lib)) coro.next() # Prime coroutine. self._call_apply_choice(coro, [self.i], importer.CHOICE_TRACKS) self.assertExists( diff --git a/test/test_ui.py b/test/test_ui.py index ad7efcc9b..874899fbe 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -249,15 +249,13 @@ class AutotagTest(unittest.TestCase): self.io.restore() def _no_candidates_test(self, result): - res = commands.choose_match( + task = importer.ImportTask( + 'toppath', 'path', - [_common.item()], # items - 'artist', - 'album', - [], # candidates - autotag.RECOMMEND_NONE, - True, False, importer.CHOICE_SKIP + [_common.item()], ) + task.set_match('artist', 'album', [], autotag.RECOMMEND_NONE) + res = commands.choose_match(task, _common.iconfig(None, quiet=False)) self.assertEqual(res, result) self.assertTrue('No match' in self.io.getoutput())