From d12a4b20dad5b65747ef3753f5b683f83e4b8eda Mon Sep 17 00:00:00 2001 From: Thomas Scholtes Date: Tue, 28 Jan 2014 23:22:00 +0100 Subject: [PATCH] Import multiple albums from single directory If a directory contains multiple albums we can select the ALBUMS action to group the tracks by album artist and album name and import those seperately. --- beets/importer.py | 29 ++++++++++++++++++-- beets/ui/commands.py | 18 ++++++++----- test/test_importer.py | 62 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 99 insertions(+), 10 deletions(-) diff --git a/beets/importer.py b/beets/importer.py index 1fa2684c7..696b8b09c 100644 --- a/beets/importer.py +++ b/beets/importer.py @@ -20,6 +20,7 @@ from __future__ import print_function import os import logging import pickle +import itertools from collections import defaultdict from beets import autotag @@ -35,7 +36,7 @@ from beets import mediafile action = enum( 'SKIP', 'ASIS', 'TRACKS', 'MANUAL', 'APPLY', 'MANUAL_ID', - name='action' + 'ALBUMS', name='action' ) QUEUE_SIZE = 128 @@ -423,7 +424,7 @@ class ImportTask(object): # Not part of the task structure: assert choice not in (action.MANUAL, action.MANUAL_ID) assert choice != action.APPLY # Only used internally. - if choice in (action.SKIP, action.ASIS, action.TRACKS): + if choice in (action.SKIP, action.ASIS, action.TRACKS, action.ALBUMS): self.choice_flag = choice self.match = None else: @@ -689,6 +690,30 @@ def user_query(session): task = pipeline.multiple(item_tasks) continue + # As albums: group items by albums and create task for each album + if choice is action.ALBUMS: + album_tasks = [] + def group(item): + return (item.albumartist or item.artist, item.album) + def emitter(): + for _, items in itertools.groupby(task.items, group): + yield ImportTask(items=list(items)) + yield ImportTask.progress_sentinel(task.toppath, task.paths) + def collector(): + while True: + album_task = yield + album_tasks.append(album_task) + ipl = pipeline.Pipeline([ + emitter(), + initial_lookup(session), + user_query(session), + collector() + ]) + ipl.run_sequential() + task = pipeline.multiple(album_tasks) + continue + + # Check for duplicates if we have a match (or ASIS). if task.choice_flag in (action.ASIS, action.APPLY): ident = task.chosen_ident() diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 61ea9f9e1..82339876c 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -472,7 +472,7 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None, .format(itemcount)) print_('For help, see: ' 'http://beets.readthedocs.org/en/latest/faq.html#nomatch') - opts = ('Use as-is', 'as Tracks', 'Skip', 'Enter search', + opts = ('Use as-is', 'as Tracks', 'as albuMs', 'Skip', 'Enter search', 'enter Id', 'aBort') sel = ui.input_options(opts) if sel == 'u': @@ -488,6 +488,8 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None, raise importer.ImportAbort() elif sel == 'i': return importer.action.MANUAL_ID + elif sel == 'm': + return importer.action.ALBUMS else: assert False @@ -538,8 +540,8 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None, opts = ('Skip', 'Use as-is', 'Enter search', 'enter Id', 'aBort') else: - opts = ('Skip', 'Use as-is', 'as Tracks', 'Enter search', - 'enter Id', 'aBort') + opts = ('Skip', 'Use as-is', 'as Tracks', 'as albuMs', + 'Enter search', 'enter Id', 'aBort') sel = ui.input_options(opts, numrange=(1, len(candidates))) if sel == 's': return importer.action.SKIP @@ -554,6 +556,8 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None, raise importer.ImportAbort() elif sel == 'i': return importer.action.MANUAL_ID + elif sel == 'm': + return importer.action.ALBUMS else: # Numerical selection. match = candidates[sel - 1] if sel != 1: @@ -577,8 +581,8 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None, opts = ('Apply', 'More candidates', 'Skip', 'Use as-is', 'Enter search', 'enter Id', 'aBort') else: - opts = ('Apply', 'More candidates', 'Skip', 'Use as-is', - 'as Tracks', 'Enter search', 'enter Id', 'aBort') + opts = ('Apply', 'more Candidates', 'Skip', 'Use as-is', + 'as Tracks', 'as albuMs', 'Enter search', 'enter Id', 'aBort') default = config['import']['default_action'].as_choice({ 'apply': 'a', 'skip': 's', @@ -591,7 +595,7 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None, if sel == 'a': return match elif sel == 'm': - pass + return importer.action.ALBUMS elif sel == 's': return importer.action.SKIP elif sel == 'u': @@ -651,7 +655,7 @@ class TerminalImportSession(importer.ImportSession): # Choose which tags to use. if choice in (importer.action.SKIP, importer.action.ASIS, - importer.action.TRACKS): + importer.action.TRACKS, importer.action.ALBUMS): # Pass selection to main control flow. return choice elif choice is importer.action.MANUAL: diff --git a/test/test_importer.py b/test/test_importer.py index a8af95360..c1682b3d7 100644 --- a/test/test_importer.py +++ b/test/test_importer.py @@ -153,13 +153,20 @@ class TestImportSession(importer.ImportSession): def choose_match(self, task): if self.choice: - return self.choice + if hasattr(self.choice, 'pop'): + return self.choice.pop(0) + else: + return self.choice else: return task.candidates[0] def choose_item(self, task): if self.item_choice: return self.item_choice + if hasattr(self.item_choice, 'pop'): + return self.item_choice.pop(0) + else: + return self.choice else: return task.candidates[0] @@ -511,6 +518,59 @@ class ImportExistingTest(_common.TestCase, ImportHelper): self.importer.run() self.assertNotExists(self.import_media[0].path) +class ImportFlatAlbumTest(_common.TestCase, ImportHelper): + def setUp(self): + super(ImportFlatAlbumTest, self).setUp() + self._setup_library() + self._create_import_dir(3) + + autotag.mb.match_album = self._match_album + autotag.mb.match_track = self._match_track + + self._setup_import_session(copy=True) + + self.importer.choice = [ + importer.action.ALBUMS, + importer.action.ASIS, + importer.action.ASIS] + + def test_add_album_for_different_artist_and_different_album(self): + self.import_media[0].artist = "Artist B" + self.import_media[0].album = "Album B" + self.import_media[0].save() + + self.importer.run() + albums = set([album.album for album in self.lib.albums()]) + self.assertEqual(albums, set(['Album B', 'Tag Album'])) + + def test_add_album_for_different_artist_and_same_albumartist(self): + self.import_media[0].artist = "Artist B" + self.import_media[0].albumartist = "Album Artist" + self.import_media[0].save() + self.import_media[1].artist = "Artist C" + self.import_media[1].albumartist = "Album Artist" + self.import_media[1].save() + + self.importer.run() + artists = set([album.albumartist for album in self.lib.albums()]) + self.assertEqual(artists, set(['Album Artist', 'Tag Artist'])) + + def test_add_album_for_same_artist_and_different_album(self): + self.import_media[0].album = "Album B" + self.import_media[0].save() + + self.importer.run() + albums = set([album.album for album in self.lib.albums()]) + self.assertEqual(albums, set(['Album B', 'Tag Album'])) + + def test_add_album_for_same_album_and_different_artist(self): + self.import_media[0].artist = "Artist B" + self.import_media[0].save() + + self.importer.run() + artists = set([album.albumartist for album in self.lib.albums()]) + self.assertEqual(artists, set(['Artist B', 'Tag Artist'])) + class InferAlbumDataTest(_common.TestCase): def setUp(self): super(InferAlbumDataTest, self).setUp()