diff --git a/beets/importer.py b/beets/importer.py index 97f360d02..951e0ac4e 100644 --- a/beets/importer.py +++ b/beets/importer.py @@ -22,6 +22,8 @@ import logging import pickle import itertools from collections import defaultdict +from tempfile import mkdtemp +import shutil from beets import autotag from beets import library @@ -525,6 +527,11 @@ class ImportTask(object): else: return [self.item] + def cleanup(self): + """Perform clean up during `finalize` stage. + """ + pass + # Utilities. def prune(self, filename): @@ -540,6 +547,78 @@ class ImportTask(object): clutter=config['clutter'].as_str_seq()) +class ArchiveImportTask(ImportTask): + """Additional methods for handling archives. + + Use when `toppath` points to a `zip`, `tar`, or `rar` archive. + """ + + def __init__(self, toppath): + super(ArchiveImportTask, self).__init__(toppath) + self.sentinel = True + self.extracted = False + + @classmethod + def is_archive(cls, path): + """Returns true if the given path points to an archive that can + be handled. + """ + if not os.path.isfile(path): + return False + + for path_test, _ in cls.handlers(): + if path_test(path): + return True + return False + + @classmethod + def handlers(cls): + """Returns a list of archive handlers. + + Each handler is a `(path_test, ArchiveClass)` tuple. `path_test` + is a function that returns `True` if the given path can be + handled by `ArchiveClass`. `ArchiveClass` is a class that + implements the same interface as `tarfile.TarFile`. + """ + if not hasattr(cls, '_handlers'): + cls._handlers = [] + from zipfile import is_zipfile, ZipFile + cls._handlers.append((is_zipfile, ZipFile)) + from tarfile import is_tarfile, TarFile + cls._handlers.append((is_tarfile, TarFile)) + try: + from rarfile import is_rarfile, RarFile + except ImportError: + pass + else: + cls._handlers.append((is_rarfile, RarFile)) + + return cls._handlers + + def cleanup(self): + """Removes the temporary directory the archive was extracted to. + """ + if self.extracted: + shutil.rmtree(self.toppath) + + def extract(self): + """Extracts the archive to a temporary directory and sets + `toppath` to that directory. + """ + for path_test, handler_class in self.handlers(): + if path_test(self.toppath): + break + + try: + extract_to = mkdtemp() + archive = handler_class(self.toppath, mode='r') + archive.extractall(extract_to) + finally: + archive.close() + self.extracted = True + self.toppath = extract_to + + # Full-album pipeline stages. def read_tasks(session): @@ -573,6 +652,24 @@ def read_tasks(session): history_dirs = history_get() for toppath in session.paths: + # Extract archives + archive_task = None + if ArchiveImportTask.is_archive(syspath(toppath)): + if not (config['import']['move'] or config['import']['copy']): + log.warn("Cannot import archive. Please set " + "the 'move' or 'copy' option.") + continue + + log.debug('extracting archive {0}' + .format(displayable_path(toppath))) + archive_task = ArchiveImportTask(toppath) + try: + archive_task.extract() + except Exception as exc: + log.error('extraction failed: {0}'.format(exc)) + continue + toppath = archive_task.toppath + # Check whether the path is to a file. if not os.path.isdir(syspath(toppath)): try: @@ -627,7 +724,11 @@ def read_tasks(session): yield ImportTask(toppath, paths, items) # Indicate the directory is finished. - yield ImportTask.done_sentinel(toppath) + # FIXME hack to delete extraced archives + if archive_task is None: + yield ImportTask.done_sentinel(toppath) + else: + yield archive_task # Show skipped directories. if config['import']['incremental'] and incremental_skipped: @@ -937,6 +1038,7 @@ def finalize(session): task.save_progress() if config['import']['incremental']: task.save_history() + task.cleanup() continue items = task.imported_items() @@ -971,6 +1073,7 @@ def finalize(session): task.save_progress() if config['import']['incremental']: task.save_history() + task.cleanup() # Singleton pipeline stages. diff --git a/docs/changelog.rst b/docs/changelog.rst index 5dcdb2bd1..88eb90789 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -25,6 +25,7 @@ New stuff: * There is also a new :doc:`/plugins/keyfinder` that runs a command line program to get the key from audio data and store it in the `initial_key` field. +* Beets can now import `zip`, `tar` and `rar` archives. Fixes: diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index 4ee434237..c7d141475 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -43,21 +43,25 @@ import `````` :: - beet import [-CWAPRqst] [-l LOGPATH] DIR... + beet import [-CWAPRqst] [-l LOGPATH] PATH... beet import [options] -L QUERY Add music to your library, attempting to get correct tags for it from MusicBrainz. -Point the command at a directory full of music. The directory can be a single -album or a directory whose leaf subdirectories are albums (the latter case is -true of typical Artist/Album organizations and many people's "downloads" -folders). The music will be copied to a configurable directory structure (see -below) and added to a library database (see below). The command is interactive -and will try to get you to verify MusicBrainz tags that it thinks are suspect. -(This means that importing a large amount of music is therefore very tedious -right now; this is something we need to work on. Read the -:doc:`autotagging guide ` if you need help.) +Point the command at a directory full of music. The directory can be a +single album or a directory whose leaf subdirectories are albums (the +latter case is true of typical Artist/Album organizations and many +people's "downloads" folders). The path can also be a single file or an +archive. Beets supports `zip` and `tar` archives out of the box. To +extract `rar` files you need to install the `rarfile`_ package and the +`unrar` command. The music will be copied to a configurable directory +structure (see below) and added to a library database (see below). The +command is interactive and will try to get you to verify MusicBrainz +tags that it thinks are suspect. (This means that importing a large +amount of music is therefore very tedious right now; this is something +we need to work on. Read the :doc:`autotagging guide ` +if you need help.) * By default, the command copies files your the library directory and updates the ID3 tags on your music. If you'd like to leave your music @@ -116,6 +120,8 @@ right now; this is something we need to work on. Read the ``--group-albums`` option to split the files based on their metadata before matching them as separate albums. +.. _rarfile: https://pypi.python.org/pypi/rarfile/2.2 + .. only:: html Reimporting diff --git a/setup.py b/setup.py index e2c917121..c602d275f 100755 --- a/setup.py +++ b/setup.py @@ -99,6 +99,7 @@ setup( 'echonest_tempo': ['pyechonest'], 'lastgenre': ['pylast'], 'web': ['flask'], + 'import': ['rarfile'], }, # Non-Python/non-PyPI plugin dependencies: # replaygain: mp3gain || aacgain diff --git a/test/rsrc/archive.rar b/test/rsrc/archive.rar new file mode 100644 index 000000000..e51051155 Binary files /dev/null and b/test/rsrc/archive.rar differ diff --git a/test/test_importer.py b/test/test_importer.py index 604c4f652..ca77ac8b0 100644 --- a/test/test_importer.py +++ b/test/test_importer.py @@ -17,10 +17,13 @@ import os import shutil import StringIO +from tempfile import mkstemp +from zipfile import ZipFile +from tarfile import TarFile import _common from _common import unittest -from helper import TestImportSession, TestHelper +from helper import TestImportSession, TestHelper, has_program from beets import library from beets import importer from beets.mediafile import MediaFile @@ -299,6 +302,60 @@ class NonAutotaggedImportTest(_common.TestCase, ImportHelper): self.assertNotExists(os.path.join(self.import_dir, 'the_album')) +class ImportZipTest(unittest.TestCase, ImportHelper): + + def setUp(self): + self.setup_beets() + + def tearDown(self): + self.teardown_beets() + + def test_import_zip(self): + zip_path = self.create_archive() + self.assertEqual(len(self.lib.items()), 0) + self.assertEqual(len(self.lib.albums()), 0) + + self._setup_import_session(autotag=False, import_dir=zip_path) + self.importer.run() + self.assertEqual(len(self.lib.items()), 1) + self.assertEqual(len(self.lib.albums()), 1) + + def create_archive(self): + (handle, path) = mkstemp(dir=self.temp_dir) + os.close(handle) + archive = ZipFile(path, mode='w') + archive.write(os.path.join(_common.RSRC, 'full.mp3'), + 'full.mp3') + archive.close() + return path + + +class ImportTarTest(ImportZipTest): + + def create_archive(self): + (handle, path) = mkstemp(dir=self.temp_dir) + os.close(handle) + archive = TarFile(path, mode='w') + archive.add(os.path.join(_common.RSRC, 'full.mp3'), + 'full.mp3') + archive.close() + return path + + +@unittest.skipIf(not has_program('unrar'), 'unrar program not found') +class ImportRarTest(ImportZipTest): + + def create_archive(self): + return os.path.join(_common.RSRC, 'archive.rar') + + +@unittest.skip('Implment me!') +class ImportPasswordRarTest(ImportZipTest): + + def create_archive(self): + return os.path.join(_common.RSRC, 'password.rar') + + class ImportSingletonTest(_common.TestCase, ImportHelper): """Test ``APPLY`` and ``ASIS`` choices for an import session with singletons config set to True. diff --git a/tox.ini b/tox.ini index 5e50071c6..e84f536ac 100644 --- a/tox.ini +++ b/tox.ini @@ -14,6 +14,7 @@ deps = flask responses pyechonest + rarfile commands = nosetests {posargs}