Skip to content

Commit a0e4120

Browse files
author
Thomas Scholtes
committed
Merge branch 'import-zip'
Closes #156
2 parents 07d1e74 + 6309765 commit a0e4120

File tree

7 files changed

+181
-12
lines changed

7 files changed

+181
-12
lines changed

beets/importer.py

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
import pickle
2323
import itertools
2424
from collections import defaultdict
25+
from tempfile import mkdtemp
26+
import shutil
2527

2628
from beets import autotag
2729
from beets import library
@@ -525,6 +527,11 @@ def imported_items(self):
525527
else:
526528
return [self.item]
527529

530+
def cleanup(self):
531+
"""Perform clean up during `finalize` stage.
532+
"""
533+
pass
534+
528535
# Utilities.
529536

530537
def prune(self, filename):
@@ -540,6 +547,78 @@ def prune(self, filename):
540547
clutter=config['clutter'].as_str_seq())
541548

542549

550+
class ArchiveImportTask(ImportTask):
551+
"""Additional methods for handling archives.
552+
553+
Use when `toppath` points to a `zip`, `tar`, or `rar` archive.
554+
"""
555+
556+
def __init__(self, toppath):
557+
super(ArchiveImportTask, self).__init__(toppath)
558+
self.sentinel = True
559+
self.extracted = False
560+
561+
@classmethod
562+
def is_archive(cls, path):
563+
"""Returns true if the given path points to an archive that can
564+
be handled.
565+
"""
566+
if not os.path.isfile(path):
567+
return False
568+
569+
for path_test, _ in cls.handlers():
570+
if path_test(path):
571+
return True
572+
return False
573+
574+
@classmethod
575+
def handlers(cls):
576+
"""Returns a list of archive handlers.
577+
578+
Each handler is a `(path_test, ArchiveClass)` tuple. `path_test`
579+
is a function that returns `True` if the given path can be
580+
handled by `ArchiveClass`. `ArchiveClass` is a class that
581+
implements the same interface as `tarfile.TarFile`.
582+
"""
583+
if not hasattr(cls, '_handlers'):
584+
cls._handlers = []
585+
from zipfile import is_zipfile, ZipFile
586+
cls._handlers.append((is_zipfile, ZipFile))
587+
from tarfile import is_tarfile, TarFile
588+
cls._handlers.append((is_tarfile, TarFile))
589+
try:
590+
from rarfile import is_rarfile, RarFile
591+
except ImportError:
592+
pass
593+
else:
594+
cls._handlers.append((is_rarfile, RarFile))
595+
596+
return cls._handlers
597+
598+
def cleanup(self):
599+
"""Removes the temporary directory the archive was extracted to.
600+
"""
601+
if self.extracted:
602+
shutil.rmtree(self.toppath)
603+
604+
def extract(self):
605+
"""Extracts the archive to a temporary directory and sets
606+
`toppath` to that directory.
607+
"""
608+
for path_test, handler_class in self.handlers():
609+
if path_test(self.toppath):
610+
break
611+
612+
try:
613+
extract_to = mkdtemp()
614+
archive = handler_class(self.toppath, mode='r')
615+
archive.extractall(extract_to)
616+
finally:
617+
archive.close()
618+
self.extracted = True
619+
self.toppath = extract_to
620+
621+
543622
# Full-album pipeline stages.
544623

545624
def read_tasks(session):
@@ -573,6 +652,24 @@ def read_tasks(session):
573652
history_dirs = history_get()
574653

575654
for toppath in session.paths:
655+
# Extract archives
656+
archive_task = None
657+
if ArchiveImportTask.is_archive(syspath(toppath)):
658+
if not (config['import']['move'] or config['import']['copy']):
659+
log.warn("Cannot import archive. Please set "
660+
"the 'move' or 'copy' option.")
661+
continue
662+
663+
log.debug('extracting archive {0}'
664+
.format(displayable_path(toppath)))
665+
archive_task = ArchiveImportTask(toppath)
666+
try:
667+
archive_task.extract()
668+
except Exception as exc:
669+
log.error('extraction failed: {0}'.format(exc))
670+
continue
671+
toppath = archive_task.toppath
672+
576673
# Check whether the path is to a file.
577674
if not os.path.isdir(syspath(toppath)):
578675
try:
@@ -627,7 +724,11 @@ def read_tasks(session):
627724
yield ImportTask(toppath, paths, items)
628725

629726
# Indicate the directory is finished.
630-
yield ImportTask.done_sentinel(toppath)
727+
# FIXME hack to delete extraced archives
728+
if archive_task is None:
729+
yield ImportTask.done_sentinel(toppath)
730+
else:
731+
yield archive_task
631732

632733
# Show skipped directories.
633734
if config['import']['incremental'] and incremental_skipped:
@@ -937,6 +1038,7 @@ def finalize(session):
9371038
task.save_progress()
9381039
if config['import']['incremental']:
9391040
task.save_history()
1041+
task.cleanup()
9401042
continue
9411043

9421044
items = task.imported_items()
@@ -971,6 +1073,7 @@ def finalize(session):
9711073
task.save_progress()
9721074
if config['import']['incremental']:
9731075
task.save_history()
1076+
task.cleanup()
9741077

9751078

9761079
# Singleton pipeline stages.

docs/changelog.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ New stuff:
2525
* There is also a new :doc:`/plugins/keyfinder` that runs a command line
2626
program to get the key from audio data and store it in the
2727
`initial_key` field.
28+
* Beets can now import `zip`, `tar` and `rar` archives.
2829

2930
Fixes:
3031

docs/reference/cli.rst

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,21 +43,25 @@ import
4343
``````
4444
::
4545

46-
beet import [-CWAPRqst] [-l LOGPATH] DIR...
46+
beet import [-CWAPRqst] [-l LOGPATH] PATH...
4747
beet import [options] -L QUERY
4848

4949
Add music to your library, attempting to get correct tags for it from
5050
MusicBrainz.
5151

52-
Point the command at a directory full of music. The directory can be a single
53-
album or a directory whose leaf subdirectories are albums (the latter case is
54-
true of typical Artist/Album organizations and many people's "downloads"
55-
folders). The music will be copied to a configurable directory structure (see
56-
below) and added to a library database (see below). The command is interactive
57-
and will try to get you to verify MusicBrainz tags that it thinks are suspect.
58-
(This means that importing a large amount of music is therefore very tedious
59-
right now; this is something we need to work on. Read the
60-
:doc:`autotagging guide </guides/tagger>` if you need help.)
52+
Point the command at a directory full of music. The directory can be a
53+
single album or a directory whose leaf subdirectories are albums (the
54+
latter case is true of typical Artist/Album organizations and many
55+
people's "downloads" folders). The path can also be a single file or an
56+
archive. Beets supports `zip` and `tar` archives out of the box. To
57+
extract `rar` files you need to install the `rarfile`_ package and the
58+
`unrar` command. The music will be copied to a configurable directory
59+
structure (see below) and added to a library database (see below). The
60+
command is interactive and will try to get you to verify MusicBrainz
61+
tags that it thinks are suspect. (This means that importing a large
62+
amount of music is therefore very tedious right now; this is something
63+
we need to work on. Read the :doc:`autotagging guide </guides/tagger>`
64+
if you need help.)
6165

6266
* By default, the command copies files your the library directory and
6367
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
116120
``--group-albums`` option to split the files based on their metadata before
117121
matching them as separate albums.
118122

123+
.. _rarfile: https://pypi.python.org/pypi/rarfile/2.2
124+
119125
.. only:: html
120126

121127
Reimporting

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ def _read(fn):
9999
'echonest_tempo': ['pyechonest'],
100100
'lastgenre': ['pylast'],
101101
'web': ['flask'],
102+
'import': ['rarfile'],
102103
},
103104
# Non-Python/non-PyPI plugin dependencies:
104105
# replaygain: mp3gain || aacgain

test/rsrc/archive.rar

2.3 KB
Binary file not shown.

test/test_importer.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,13 @@
1717
import os
1818
import shutil
1919
import StringIO
20+
from tempfile import mkstemp
21+
from zipfile import ZipFile
22+
from tarfile import TarFile
2023

2124
import _common
2225
from _common import unittest
23-
from helper import TestImportSession, TestHelper
26+
from helper import TestImportSession, TestHelper, has_program
2427
from beets import library
2528
from beets import importer
2629
from beets.mediafile import MediaFile
@@ -299,6 +302,60 @@ def test_import_with_delete_prunes_directory_empty(self):
299302
self.assertNotExists(os.path.join(self.import_dir, 'the_album'))
300303

301304

305+
class ImportZipTest(unittest.TestCase, ImportHelper):
306+
307+
def setUp(self):
308+
self.setup_beets()
309+
310+
def tearDown(self):
311+
self.teardown_beets()
312+
313+
def test_import_zip(self):
314+
zip_path = self.create_archive()
315+
self.assertEqual(len(self.lib.items()), 0)
316+
self.assertEqual(len(self.lib.albums()), 0)
317+
318+
self._setup_import_session(autotag=False, import_dir=zip_path)
319+
self.importer.run()
320+
self.assertEqual(len(self.lib.items()), 1)
321+
self.assertEqual(len(self.lib.albums()), 1)
322+
323+
def create_archive(self):
324+
(handle, path) = mkstemp(dir=self.temp_dir)
325+
os.close(handle)
326+
archive = ZipFile(path, mode='w')
327+
archive.write(os.path.join(_common.RSRC, 'full.mp3'),
328+
'full.mp3')
329+
archive.close()
330+
return path
331+
332+
333+
class ImportTarTest(ImportZipTest):
334+
335+
def create_archive(self):
336+
(handle, path) = mkstemp(dir=self.temp_dir)
337+
os.close(handle)
338+
archive = TarFile(path, mode='w')
339+
archive.add(os.path.join(_common.RSRC, 'full.mp3'),
340+
'full.mp3')
341+
archive.close()
342+
return path
343+
344+
345+
@unittest.skipIf(not has_program('unrar'), 'unrar program not found')
346+
class ImportRarTest(ImportZipTest):
347+
348+
def create_archive(self):
349+
return os.path.join(_common.RSRC, 'archive.rar')
350+
351+
352+
@unittest.skip('Implment me!')
353+
class ImportPasswordRarTest(ImportZipTest):
354+
355+
def create_archive(self):
356+
return os.path.join(_common.RSRC, 'password.rar')
357+
358+
302359
class ImportSingletonTest(_common.TestCase, ImportHelper):
303360
"""Test ``APPLY`` and ``ASIS`` choices for an import session with singletons
304361
config set to True.

tox.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ deps =
1414
flask
1515
responses
1616
pyechonest
17+
rarfile
1718
commands =
1819
nosetests {posargs}
1920

0 commit comments

Comments
 (0)