diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index ebdb65f99..cab804370 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -315,6 +315,16 @@ def distance(items, info): else: return dist/dist_max +def apply_item_metadata(item, track_data): + """Set an item's metadata from its matched info dictionary. + """ + item.artist = track_data['artist'] + item.title = track_data['title'] + item.mb_trackid = track_data['id'] + if 'artist_id' in track_data: + item.mb_artistid = track_data['artist_id'] + #TODO clear out other data? + def apply_metadata(items, info): """Set the items' metadata to match the data given in info. The list of items must be ordered. @@ -535,6 +545,7 @@ def tag_item(item): # candidates.extend(plugins.item_candidates(item)) # Sort by distance and return with recommendation. + log.debug('Found %i candidates.' % len(candidates)) candidates.sort() rec = recommendation(candidates) return candidates, rec diff --git a/beets/importer.py b/beets/importer.py index 1f15a0d65..ef8c45f85 100644 --- a/beets/importer.py +++ b/beets/importer.py @@ -29,7 +29,7 @@ from beets.util import syspath, normpath from beets.util.enumeration import enum action = enum( - 'SKIP', 'ASIS', 'TRACKS', 'MANUAL', 'ALBUM', + 'SKIP', 'ASIS', 'TRACKS', 'MANUAL', 'APPLY', name='action' ) @@ -124,7 +124,8 @@ class ImportConfig(object): """ _fields = ['lib', 'paths', 'resume', 'logfile', 'color', 'quiet', 'quiet_fallback', 'copy', 'write', 'art', 'delete', - 'choose_match_func', 'should_resume_func', 'threaded', 'autot'] + 'choose_match_func', 'should_resume_func', 'threaded', + 'autot', 'items'] def __init__(self, **kwargs): for slot in self._fields: setattr(self, slot, kwargs[slot]) @@ -185,9 +186,14 @@ class ImportTask(object): one for each item. """ assert len(self.items) == len(item_matches) - self.item_candidates = item_matches + self.item_matches = item_matches self.is_album = False + def set_item_match(self, candidates, rec): + """Set the match for a single-item task.""" + assert len(self.items) == 1 + self.item_matches = [(candidates, rec)] + def set_null_item_match(self): """For single-item tasks, mark the item as having no matches. """ @@ -202,7 +208,7 @@ class ImportTask(object): """ assert not self.sentinel assert choice != action.MANUAL # Not part of the task structure. - assert choice != action.ALBUM # Only used internally. + assert choice != action.APPLY # Only used internally. if choice in (action.SKIP, action.ASIS, action.TRACKS): self.choice_flag = choice self.info = None @@ -212,7 +218,7 @@ class ImportTask(object): info, items = choice self.items = items # Reordered items list. self.info = info - self.choice_flag = action.ALBUM # Implicit choice. + self.choice_flag = action.APPLY # Implicit choice. def save_progress(self): """Updates the progress state to indicate that this album has @@ -226,8 +232,9 @@ class ImportTask(object): # Logical decisions. def should_create_album(self): """Should an album structure be created for these items?""" - assert self.is_album - if self.choice_flag in (action.ALBUM, action.ASIS): + if not self.is_album: + return False + elif self.choice_flag in (action.APPLY, action.ASIS): return True elif self.choice_flag in (action.TRACKS, action.SKIP): return False @@ -235,7 +242,7 @@ class ImportTask(object): assert False def should_write_tags(self): """Should new info be written to the files' metadata?""" - if self.choice_flag == action.ALBUM: + if self.choice_flag == action.APPLY: return True elif self.choice_flag in (action.ASIS, action.TRACKS, action.SKIP): return False @@ -248,8 +255,9 @@ class ImportTask(object): """When creating an album structure, should the album artist field be inferred from the plurality of track artists? """ + assert self.is_album assert self.should_create_album() - if self.choice_flag == action.ALBUM: + if self.choice_flag == action.APPLY: # Album artist comes from the info dictionary. return False elif self.choice_flag == action.ASIS: @@ -395,7 +403,11 @@ def apply_choices(config): # Change metadata, move, and copy. if task.should_write_tags(): - autotag.apply_metadata(task.items, task.info) + if task.is_album: + autotag.apply_metadata(task.items, task.info) + else: + for item, info in zip(task.items, task.info): + autotag.apply_item_metadata(item, info) if config.copy and config.delete: old_paths = [os.path.realpath(item.path) for item in task.items] @@ -445,7 +457,7 @@ def apply_choices(config): task.save_progress() -# Single-item pipeline stages. +# Individual-item pipeline stages. def read_items(config): """Reads individual items by recursively descending into a set of @@ -464,7 +476,7 @@ def item_lookup(config): task = None while True: task = yield task - task.set_null_item_match() #TODO + task.set_item_match(*autotag.tag_item(task.items[0])) def item_query(config): """A coroutine that queries the user for input on single-item @@ -473,7 +485,7 @@ def item_query(config): task = None while True: task = yield task - task.set_choice(action.ASIS) # TODO + task.set_choice(action.ASIS) #TODO actually query user # Main driver. @@ -485,13 +497,19 @@ def run_import(**kwargs): config = ImportConfig(**kwargs) # Set up the pipeline. - stages = [read_albums(config)] - if config.autot: - # Only look up and query the user when autotagging. - stages += [initial_lookup(config), user_query(config)] + if config.items: + # Individual item importer. + stages = [read_items(config), item_lookup(config), item_query(config)] + #TODO non-autotagged else: - # When not autotagging, just display progress. - stages += [show_progress(config)] + # Whole-album importer. + stages = [read_albums(config)] + if config.autot: + # Only look up and query the user when autotagging. + stages += [initial_lookup(config), user_query(config)] + else: + # When not autotagging, just display progress. + stages += [show_progress(config)] stages += [apply_choices(config)] pl = pipeline.Pipeline(stages) diff --git a/beets/ui/commands.py b/beets/ui/commands.py index be2e227ac..47c96abf7 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -283,7 +283,7 @@ def choose_match(task, config): # The import command. def import_files(lib, paths, copy, write, autot, logpath, art, threaded, - color, delete, quiet, resume, quiet_fallback): + color, delete, quiet, resume, quiet_fallback, items): """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 @@ -332,6 +332,7 @@ def import_files(lib, paths, copy, write, autot, logpath, art, threaded, autot = autot, choose_match_func = choose_match, should_resume_func = should_resume, + items = items, ) # If we were logging, close the file. @@ -368,6 +369,8 @@ import_cmd.parser.add_option('-q', '--quiet', action='store_true', dest='quiet', help="never prompt for input: skip albums instead") import_cmd.parser.add_option('-l', '--log', dest='logpath', help='file to log untaggable albums for later review') +import_cmd.parser.add_option('-i', '--items', dest='items', + help='import individual tracks instead of full albums') def import_func(lib, config, opts, args): copy = opts.copy if opts.copy is not None else \ ui.config_val(config, 'beets', 'import_copy', @@ -387,6 +390,7 @@ def import_func(lib, config, opts, args): quiet = opts.quiet if opts.quiet is not None else DEFAULT_IMPORT_QUIET quiet_fallback_str = ui.config_val(config, 'beets', 'import_quiet_fallback', DEFAULT_IMPORT_QUIET_FALLBACK) + items = opts.items # Resume has three options: yes, no, and "ask" (None). resume = opts.resume if opts.resume is not None else \ @@ -404,7 +408,7 @@ def import_func(lib, config, opts, args): else: quiet_fallback = importer.action.SKIP import_files(lib, args, copy, write, autot, opts.logpath, art, threaded, - color, delete, quiet, resume, quiet_fallback) + color, delete, quiet, resume, quiet_fallback, items) import_cmd.func = import_func default_commands.append(import_cmd) diff --git a/test/_common.py b/test/_common.py index d4c4ddb31..8d0ed6cba 100644 --- a/test/_common.py +++ b/test/_common.py @@ -79,6 +79,7 @@ def iconfig(lib, **kwargs): should_resume_func = lambda _: False, threaded = False, autot = True, + items = False, ) for k, v in kwargs.items(): setattr(config, k, v) diff --git a/test/test_importer.py b/test/test_importer.py index 80d069d11..5be15e5bc 100644 --- a/test/test_importer.py +++ b/test/test_importer.py @@ -27,7 +27,7 @@ TEST_TITLES = ('The Opener','The Second Track','The Last Track') class NonAutotaggedImportTest(unittest.TestCase): def setUp(self): self.io = _common.DummyIO() - #self.io.install() + self.io.install() self.libdb = os.path.join(_common.RSRC, 'testlib.blb') self.lib = library.Library(self.libdb) @@ -96,6 +96,7 @@ class NonAutotaggedImportTest(unittest.TestCase): quiet_fallback='skip', choose_match_func = None, should_resume_func = None, + items=False, ) return paths