diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index e05c16df5..e6588ee30 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -33,13 +33,14 @@ log = logging.getLogger('beets') # Additional utilities for the main interface. -def albums_in_dir(path): +def albums_in_dir(path, ignore=()): """Recursively searches the given directory and returns an iterable of (path, items) where path is a containing directory and items is a list of Items that is probably an album. Specifically, any folder - containing any media files is an album. + containing any media files is an album. Directories and file names + that match the glob patterns in ``ignore`` are skipped. """ - for root, dirs, files in sorted_walk(path): + for root, dirs, files in sorted_walk(path, ignore): # Get a list of items in the directory. items = [] for filename in files: diff --git a/beets/importer.py b/beets/importer.py index 9ce9e07a8..2b24047bf 100644 --- a/beets/importer.py +++ b/beets/importer.py @@ -265,7 +265,7 @@ class ImportConfig(object): 'quiet_fallback', 'copy', 'write', 'art', 'delete', 'choose_match_func', 'should_resume_func', 'threaded', 'autot', 'singletons', 'timid', 'choose_item_func', - 'query', 'incremental'] + 'query', 'incremental', 'ignore'] def __init__(self, **kwargs): for slot in self._fields: setattr(self, slot, kwargs[slot]) @@ -455,7 +455,7 @@ def read_tasks(config): # Produce paths under this directory. if progress: resume_dir = resume_dirs.get(toppath) - for path, items in autotag.albums_in_dir(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. diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 4ae9900e4..53cd95667 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -257,10 +257,10 @@ def input_yn(prompt, require=False, color=False): return sel == 'y' def config_val(config, section, name, default, vtype=None): - """Queries the configuration file for a value (given by the - section and name). If no value is present, returns default. - vtype optionally specifies the return type (although only bool - is supported for now). + """Queries the configuration file for a value (given by the section + and name). If no value is present, returns default. vtype + optionally specifies the return type (although only ``bool`` and + ``list`` are supported for now). """ if not config.has_section(section): config.add_section(section) @@ -268,6 +268,10 @@ def config_val(config, section, name, default, vtype=None): try: if vtype is bool: return config.getboolean(section, name) + elif vtype is list: + # Whitespace-separated strings. + strval = config.get(section, name) + return strval.split() else: return config.get(section, name) except ConfigParser.NoOptionError: diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 0ae82c955..b6516e1bd 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -97,6 +97,7 @@ DEFAULT_IMPORT_RESUME = None # "ask" DEFAULT_IMPORT_INCREMENTAL = False DEFAULT_THREADED = True DEFAULT_COLOR = True +DEFAULT_IGNORE = ['.AppleDouble', '._*', '*~', '.DS_Store'] VARIOUS_ARTISTS = u'Various Artists' @@ -506,7 +507,7 @@ def choose_item(task, config): def import_files(lib, paths, copy, write, autot, logpath, art, threaded, color, delete, quiet, resume, quiet_fallback, singletons, - timid, query, incremental): + 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 @@ -568,6 +569,7 @@ def import_files(lib, paths, copy, write, autot, logpath, art, threaded, choose_item_func = choose_item, query = query, incremental = incremental, + ignore = ignore, ) # If we were logging, close the file. @@ -641,6 +643,7 @@ def import_func(lib, config, opts, args): incremental = opts.incremental if opts.incremental is not None else \ ui.config_val(config, 'beets', 'import_incremental', DEFAULT_IMPORT_INCREMENTAL, bool) + ignore = ui.config_val(config, 'beets', 'ignore', DEFAULT_IGNORE, list) # Resume has three options: yes, no, and "ask" (None). resume = opts.resume if opts.resume is not None else \ @@ -667,7 +670,7 @@ def import_func(lib, config, opts, args): import_files(lib, paths, copy, write, autot, logpath, art, threaded, color, delete, quiet, resume, quiet_fallback, singletons, - timid, query, incremental) + timid, query, incremental, ignore) import_cmd.func = import_func default_commands.append(import_cmd) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 0f8d20dfc..476fd82a1 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -17,6 +17,7 @@ import os import sys import re import shutil +import fnmatch from collections import defaultdict MAX_FILENAME_LENGTH = 200 @@ -47,9 +48,10 @@ def ancestry(path, pathmod=None): out.insert(0, path) return out -def sorted_walk(path): - """Like os.walk, but yields things in sorted, breadth-first - order. +def sorted_walk(path, ignore=()): + """Like ``os.walk``, but yields things in sorted, breadth-first + order. Directory and file names matching any glob pattern in + ``ignore`` are skipped. """ # Make sure the path isn't a Unicode string. path = bytestring_path(path) @@ -58,6 +60,16 @@ def sorted_walk(path): dirs = [] files = [] for base in os.listdir(path): + # Skip ignored filenames. + skip = False + for pat in ignore: + if fnmatch.fnmatch(base, pat): + skip = True + break + if skip: + continue + + # Add to output as either a file or a directory. cur = os.path.join(path, base) if os.path.isdir(syspath(cur)): dirs.append(base) diff --git a/docs/changelog.rst b/docs/changelog.rst index d372c08c9..c8d4ab578 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -23,6 +23,9 @@ Changelog * When entering an ID manually during tagging, beets now searches for anything that looks like an MBID in the entered string. This means that full MusicBrainz URLs now work as IDs at the prompt. (Thanks to derwin.) +* The importer now ignores certain "clutter" files like ``.AppleDouble`` + directories and ``._*`` files. The list of ignored patterns is configurable + via the ``ignore`` setting; see :doc:`/reference/config`. * Fix a crash after using the "as Tracks" option during import. * Fix a Unicode error when tagging items with missing titles. diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 736869d78..75667e411 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -53,6 +53,10 @@ must appear under the ``[beets]`` section header: By default, no log is written. This can be overridden with the ``-l`` flag to ``import``. +* ``ignore``: a space-separated list of glob patterns specifying file and + directory names to be ignored when importing. Defaults to + ``.AppleDouble ._* *~ .DS_Store``. + * ``art_filename``: when importing album art, the name of the file (without extension) where the cover art image should be placed. Defaults to ``cover`` (i.e., images will be named ``cover.jpg`` or ``cover.png`` and placed in the @@ -109,6 +113,7 @@ Here's an example file:: import_quiet_fallback: skip import_timid: no import_log: beetslog.txt + ignore: .AppleDouble ._* *~ .DS_Store art_filename: albumart plugins: bpd pluginpath: ~/beets/myplugins diff --git a/test/_common.py b/test/_common.py index f3cfff073..f6d87109a 100644 --- a/test/_common.py +++ b/test/_common.py @@ -88,6 +88,7 @@ def iconfig(lib, **kwargs): timid = False, query = None, incremental = False, + ignore = [], ) for k, v in kwargs.items(): setattr(config, k, v) diff --git a/test/test_files.py b/test/test_files.py index f3dbb4915..bf61ea0d8 100644 --- a/test/test_files.py +++ b/test/test_files.py @@ -493,6 +493,46 @@ class PruneTest(unittest.TestCase, _common.ExtraAsserts): self.assertExists(self.base) self.assertNotExists(self.sub) +class WalkTest(unittest.TestCase): + def setUp(self): + self.base = os.path.join(_common.RSRC, 'testdir') + os.mkdir(self.base) + touch(os.path.join(self.base, 'y')) + touch(os.path.join(self.base, 'x')) + os.mkdir(os.path.join(self.base, 'd')) + touch(os.path.join(self.base, 'd', 'z')) + def tearDown(self): + if os.path.exists(self.base): + shutil.rmtree(self.base) + + def test_sorted_files(self): + res = list(util.sorted_walk(self.base)) + self.assertEqual(len(res), 2) + self.assertEqual(res[0], + (self.base, ['d'], ['x', 'y'])) + self.assertEqual(res[1], + (os.path.join(self.base, 'd'), [], ['z'])) + + def test_ignore_file(self): + res = list(util.sorted_walk(self.base, ('x',))) + self.assertEqual(len(res), 2) + self.assertEqual(res[0], + (self.base, ['d'], ['y'])) + self.assertEqual(res[1], + (os.path.join(self.base, 'd'), [], ['z'])) + + def test_ignore_directory(self): + res = list(util.sorted_walk(self.base, ('d',))) + self.assertEqual(len(res), 1) + self.assertEqual(res[0], + (self.base, [], ['x', 'y'])) + + def test_ignore_everything(self): + res = list(util.sorted_walk(self.base, ('*',))) + self.assertEqual(len(res), 1) + self.assertEqual(res[0], + (self.base, [], [])) + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) diff --git a/test/test_importer.py b/test/test_importer.py index 22809c43f..be69622df 100644 --- a/test/test_importer.py +++ b/test/test_importer.py @@ -103,6 +103,7 @@ class NonAutotaggedImportTest(unittest.TestCase): timid = False, query = None, incremental = False, + ignore = [], ) return paths diff --git a/test/test_ui.py b/test/test_ui.py index f22aa907d..155af8802 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -431,7 +431,7 @@ class ImportTest(unittest.TestCase): self.assertRaises(ui.UserError, commands.import_files, None, [], False, False, False, None, False, False, False, False, True, False, None, False, True, None, - False) + False, []) class InputTest(unittest.TestCase): def setUp(self):