From c5032f925ee2eb9e226e874d7614bce3bb3635b7 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 17 Sep 2013 09:09:10 -0700 Subject: [PATCH] move `Destination` method to Item class --- beets/library.py | 143 +++++++++++++++++++------------------- beets/vfs.py | 2 +- beetsplug/bpd/__init__.py | 2 +- beetsplug/convert.py | 12 ++-- test/_common.py | 7 +- test/test_db.py | 90 ++++++++++++------------ test/test_files.py | 14 ++-- 7 files changed, 135 insertions(+), 135 deletions(-) diff --git a/beets/library.py b/beets/library.py index ac4de7a36..b227a979f 100644 --- a/beets/library.py +++ b/beets/library.py @@ -478,7 +478,7 @@ class Item(LibModel): None if the item is a singleton. """ self._check_db() - return self._lib.get_album(self.id) + return self._lib.get_album(self) # Interaction with file metadata. @@ -607,7 +607,7 @@ class Item(LibModel): transaction. """ self._check_db() - dest = self._lib.destination(self, basedir=basedir) + dest = self.destination(basedir=basedir) # Create necessary ancestry for the move. util.mkdirall(dest) @@ -694,6 +694,76 @@ class Item(LibModel): # Perform substitution. return template.substitute(mapping, funcs) + def destination(self, pathmod=None, fragment=False, + basedir=None, platform=None, path_formats=None): + """Returns the path in the library directory designated for the + item (i.e., where the file ought to be). fragment makes this + method return just the path fragment underneath the root library + directory; the path is also returned as Unicode instead of + encoded as a bytestring. basedir can override the library's base + directory for the destination. + """ + self._check_db() + pathmod = pathmod or os.path + platform = platform or sys.platform + basedir = basedir or self._lib.directory + path_formats = path_formats or self._lib.path_formats + + # Use a path format based on a query, falling back on the + # default. + for query, path_format in path_formats: + if query == PF_KEY_DEFAULT: + continue + query = AndQuery.from_string(query) + if query.match(self): + # The query matches the item! Use the corresponding path + # format. + break + else: + # No query matched; fall back to default. + for query, path_format in path_formats: + if query == PF_KEY_DEFAULT: + break + else: + assert False, "no default path format" + if isinstance(path_format, Template): + subpath_tmpl = path_format + else: + subpath_tmpl = Template(path_format) + + # Evaluate the selected template. + subpath = self.evaluate_template(subpath_tmpl, True, pathmod) + + # Prepare path for output: normalize Unicode characters. + if platform == 'darwin': + subpath = unicodedata.normalize('NFD', subpath) + else: + subpath = unicodedata.normalize('NFC', subpath) + # Truncate components and remove forbidden characters. + subpath = util.sanitize_path(subpath, pathmod, self._lib.replacements) + # Encode for the filesystem. + if not fragment: + subpath = bytestring_path(subpath) + + # Preserve extension. + _, extension = pathmod.splitext(self.path) + if fragment: + # Outputting Unicode. + extension = extension.decode('utf8', 'ignore') + subpath += extension.lower() + + # Truncate too-long components. + maxlen = beets.config['max_filename_length'].get(int) + if not maxlen: + # When zero, try to determine from filesystem. + maxlen = util.max_filename_length(self._lib.directory) + subpath = util.truncate_path(subpath, pathmod, maxlen) + + if fragment: + return subpath + else: + return normpath(os.path.join(basedir, subpath)) + class Album(LibModel): """Provides access to information about albums stored in a @@ -1636,75 +1706,6 @@ class Library(object): """ return Transaction(self) - def destination(self, item, pathmod=None, fragment=False, - basedir=None, platform=None, path_formats=None): - """Returns the path in the library directory designated for item - item (i.e., where the file ought to be). fragment makes this - method return just the path fragment underneath the root library - directory; the path is also returned as Unicode instead of - encoded as a bytestring. basedir can override the library's base - directory for the destination. - """ - pathmod = pathmod or os.path - platform = platform or sys.platform - basedir = basedir or self.directory - path_formats = path_formats or self.path_formats - - # Use a path format based on a query, falling back on the - # default. - for query, path_format in path_formats: - if query == PF_KEY_DEFAULT: - continue - query = AndQuery.from_string(query) - if query.match(item): - # The query matches the item! Use the corresponding path - # format. - break - else: - # No query matched; fall back to default. - for query, path_format in path_formats: - if query == PF_KEY_DEFAULT: - break - else: - assert False, "no default path format" - if isinstance(path_format, Template): - subpath_tmpl = path_format - else: - subpath_tmpl = Template(path_format) - - # Evaluate the selected template. - subpath = item.evaluate_template(subpath_tmpl, True, pathmod) - - # Prepare path for output: normalize Unicode characters. - if platform == 'darwin': - subpath = unicodedata.normalize('NFD', subpath) - else: - subpath = unicodedata.normalize('NFC', subpath) - # Truncate components and remove forbidden characters. - subpath = util.sanitize_path(subpath, pathmod, self.replacements) - # Encode for the filesystem. - if not fragment: - subpath = bytestring_path(subpath) - - # Preserve extension. - _, extension = pathmod.splitext(item.path) - if fragment: - # Outputting Unicode. - extension = extension.decode('utf8', 'ignore') - subpath += extension.lower() - - # Truncate too-long components. - maxlen = beets.config['max_filename_length'].get(int) - if not maxlen: - # When zero, try to determine from filesystem. - maxlen = util.max_filename_length(self.directory) - subpath = util.truncate_path(subpath, pathmod, maxlen) - - if fragment: - return subpath - else: - return normpath(os.path.join(basedir, subpath)) - # Adding objects to the database. diff --git a/beets/vfs.py b/beets/vfs.py index c7c8d27d9..235f36048 100644 --- a/beets/vfs.py +++ b/beets/vfs.py @@ -42,7 +42,7 @@ def libtree(lib): """ root = Node({}, {}) for item in lib.items(): - dest = lib.destination(item, fragment=True) + dest = item.destination(fragment=True) parts = util.components(dest) _insert(root, parts, item.id) return root diff --git a/beetsplug/bpd/__init__.py b/beetsplug/bpd/__init__.py index ef00c1c31..1faa5f1b9 100644 --- a/beetsplug/bpd/__init__.py +++ b/beetsplug/bpd/__init__.py @@ -751,7 +751,7 @@ class Server(BaseServer): # Metadata helper functions. def _item_info(self, item): - info_lines = [u'file: ' + self.lib.destination(item, fragment=True), + info_lines = [u'file: ' + item.destination(fragment=True), u'Time: ' + unicode(int(item.length)), u'Title: ' + item.title, u'Artist: ' + item.artist, diff --git a/beetsplug/convert.py b/beetsplug/convert.py index d1bfa9ddd..eaaf6809d 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -32,11 +32,11 @@ _fs_lock = threading.Lock() _temp_files = [] # Keep track of temporary transcoded files for deletion. -def _destination(lib, dest_dir, item, keep_new, path_formats): +def _destination(dest_dir, item, keep_new, path_formats): """Return the path under `dest_dir` where the file should be placed (possibly after conversion). """ - dest = lib.destination(item, basedir=dest_dir, path_formats=path_formats) + dest = item.destination(basedir=dest_dir, path_formats=path_formats) if keep_new: # When we're keeping the converted file, no extension munging # occurs. @@ -129,10 +129,10 @@ def should_transcode(item): return item.format not in ['AAC', 'MP3', 'Opus', 'OGG', 'Windows Media'] or item.bitrate >= 1000 * maxbr -def convert_item(lib, dest_dir, keep_new, path_formats): +def convert_item(dest_dir, keep_new, path_formats): while True: item = yield - dest = _destination(lib, dest_dir, item, keep_new, path_formats) + dest = _destination(dest_dir, item, keep_new, path_formats) if os.path.exists(util.syspath(dest)): log.info(u'Skipping {0} (target file exists)'.format( @@ -181,7 +181,7 @@ def convert_item(lib, dest_dir, keep_new, path_formats): item.store() # Store new path and audio data. if config['convert']['embed']: - album = lib.get_album(item) + album = item.get_album() if album: artpath = album.artpath if artpath: @@ -229,7 +229,7 @@ def convert_func(lib, opts, args): items = (i for a in lib.albums(ui.decargs(args)) for i in a.items()) else: items = iter(lib.items(ui.decargs(args))) - convert = [convert_item(lib, dest, keep_new, path_formats) for i in range(threads)] + convert = [convert_item(dest, keep_new, path_formats) for i in range(threads)] pipe = util.pipeline.Pipeline([items, convert]) pipe.run_parallel() diff --git a/test/_common.py b/test/_common.py index f4b4fe37c..b8963d87b 100644 --- a/test/_common.py +++ b/test/_common.py @@ -42,10 +42,10 @@ RSRC = os.path.join(os.path.dirname(__file__), 'rsrc') # Dummy item creation. _item_ident = 0 -def item(): +def item(lib=None): global _item_ident _item_ident += 1 - return beets.library.Item( + i = beets.library.Item( title = u'the title', artist = u'the artist', albumartist = u'the album artist', @@ -74,6 +74,9 @@ def item(): mb_albumartistid = 'someID-4', album_id = None, ) + if lib: + lib.add(i) + return i # Dummy import session. def import_session(lib=None, logfile=None, paths=[], query=[], cli=False): diff --git a/test/test_db.py b/test/test_db.py index 8a3ad2313..68148d01c 100644 --- a/test/test_db.py +++ b/test/test_db.py @@ -152,7 +152,7 @@ class DestinationTest(unittest.TestCase): def setUp(self): super(DestinationTest, self).setUp() self.lib = beets.library.Library(':memory:') - self.i = item() + self.i = item(self.lib) def tearDown(self): super(DestinationTest, self).tearDown() self.lib._connection().close() @@ -160,12 +160,12 @@ class DestinationTest(unittest.TestCase): def test_directory_works_with_trailing_slash(self): self.lib.directory = 'one/' self.lib.path_formats = [('default', 'two')] - self.assertEqual(self.lib.destination(self.i), np('one/two')) + self.assertEqual(self.i.destination(), np('one/two')) def test_directory_works_without_trailing_slash(self): self.lib.directory = 'one' self.lib.path_formats = [('default', 'two')] - self.assertEqual(self.lib.destination(self.i), np('one/two')) + self.assertEqual(self.i.destination(), np('one/two')) def test_destination_substitues_metadata_values(self): self.lib.directory = 'base' @@ -173,21 +173,21 @@ class DestinationTest(unittest.TestCase): self.i.title = 'three' self.i.artist = 'two' self.i.album = 'one' - self.assertEqual(self.lib.destination(self.i), + self.assertEqual(self.i.destination(), np('base/one/two three')) def test_destination_preserves_extension(self): self.lib.directory = 'base' self.lib.path_formats = [('default', '$title')] self.i.path = 'hey.audioformat' - self.assertEqual(self.lib.destination(self.i), + self.assertEqual(self.i.destination(), np('base/the title.audioformat')) def test_lower_case_extension(self): self.lib.directory = 'base' self.lib.path_formats = [('default', '$title')] self.i.path = 'hey.MP3' - self.assertEqual(self.lib.destination(self.i), + self.assertEqual(self.i.destination(), np('base/the title.mp3')) def test_destination_pads_some_indices(self): @@ -199,7 +199,7 @@ class DestinationTest(unittest.TestCase): self.i.disc = 3 self.i.disctotal = 4 self.i.bpm = 5 - self.assertEqual(self.lib.destination(self.i), + self.assertEqual(self.i.destination(), np('base/01 02 03 04 5')) def test_destination_pads_date_values(self): @@ -208,43 +208,43 @@ class DestinationTest(unittest.TestCase): self.i.year = 1 self.i.month = 2 self.i.day = 3 - self.assertEqual(self.lib.destination(self.i), + self.assertEqual(self.i.destination(), np('base/0001-02-03')) def test_destination_escapes_slashes(self): self.i.album = 'one/two' - dest = self.lib.destination(self.i) + dest = self.i.destination() self.assertTrue('one' in dest) self.assertTrue('two' in dest) self.assertFalse('one/two' in dest) def test_destination_escapes_leading_dot(self): self.i.album = '.something' - dest = self.lib.destination(self.i) + dest = self.i.destination() self.assertTrue('something' in dest) self.assertFalse('/.' in dest) def test_destination_preserves_legitimate_slashes(self): self.i.artist = 'one' self.i.album = 'two' - dest = self.lib.destination(self.i) + dest = self.i.destination() self.assertTrue(os.path.join('one', 'two') in dest) def test_destination_long_names_truncated(self): self.i.title = 'X'*300 self.i.artist = 'Y'*300 - for c in self.lib.destination(self.i).split(os.path.sep): + for c in self.i.destination().split(os.path.sep): self.assertTrue(len(c) <= 255) def test_destination_long_names_keep_extension(self): self.i.title = 'X'*300 self.i.path = 'something.extn' - dest = self.lib.destination(self.i) + dest = self.i.destination() self.assertEqual(dest[-5:], '.extn') def test_distination_windows_removes_both_separators(self): self.i.title = 'one \\ two / three.mp3' - p = self.lib.destination(self.i, ntpath) + p = self.i.destination(pathmod=ntpath) self.assertFalse('one \\ two' in p) self.assertFalse('one / two' in p) self.assertFalse('two \\ three' in p) @@ -270,7 +270,7 @@ class DestinationTest(unittest.TestCase): def test_path_with_format(self): self.lib.path_formats = [('default', '$artist/$album ($format)')] - p = self.lib.destination(self.i) + p = self.i.destination() self.assert_('(FLAC)' in p) def test_heterogeneous_album_gets_single_directory(self): @@ -278,7 +278,7 @@ class DestinationTest(unittest.TestCase): self.lib.add_album([i1, i2]) i1.year, i2.year = 2009, 2010 self.lib.path_formats = [('default', '$album ($year)/$track $title')] - dest1, dest2 = self.lib.destination(i1), self.lib.destination(i2) + dest1, dest2 = i1.destination(), i2.destination() self.assertEqual(os.path.dirname(dest1), os.path.dirname(dest2)) def test_default_path_for_non_compilations(self): @@ -287,20 +287,20 @@ class DestinationTest(unittest.TestCase): self.lib.directory = 'one' self.lib.path_formats = [('default', 'two'), ('comp:true', 'three')] - self.assertEqual(self.lib.destination(self.i), np('one/two')) + self.assertEqual(self.i.destination(), np('one/two')) def test_singleton_path(self): - i = item() + i = item(self.lib) self.lib.directory = 'one' self.lib.path_formats = [ ('default', 'two'), ('singleton:true', 'four'), ('comp:true', 'three'), ] - self.assertEqual(self.lib.destination(i), np('one/four')) + self.assertEqual(i.destination(), np('one/four')) def test_comp_before_singleton_path(self): - i = item() + i = item(self.lib) i.comp = True self.lib.directory = 'one' self.lib.path_formats = [ @@ -308,7 +308,7 @@ class DestinationTest(unittest.TestCase): ('comp:true', 'three'), ('singleton:true', 'four'), ] - self.assertEqual(self.lib.destination(i), np('one/three')) + self.assertEqual(i.destination(), np('one/three')) def test_comp_path(self): self.i.comp = True @@ -318,7 +318,7 @@ class DestinationTest(unittest.TestCase): ('default', 'two'), ('comp:true', 'three'), ] - self.assertEqual(self.lib.destination(self.i), np('one/three')) + self.assertEqual(self.i.destination(), np('one/three')) def test_albumtype_query_path(self): self.i.comp = True @@ -330,7 +330,7 @@ class DestinationTest(unittest.TestCase): ('albumtype:sometype', 'four'), ('comp:true', 'three'), ] - self.assertEqual(self.lib.destination(self.i), np('one/four')) + self.assertEqual(self.i.destination(), np('one/four')) def test_albumtype_path_fallback_to_comp(self): self.i.comp = True @@ -342,7 +342,7 @@ class DestinationTest(unittest.TestCase): ('albumtype:anothertype', 'four'), ('comp:true', 'three'), ] - self.assertEqual(self.lib.destination(self.i), np('one/three')) + self.assertEqual(self.i.destination(), np('one/three')) def test_sanitize_windows_replaces_trailing_space(self): p = util.sanitize_path(u'one/two /three', ntpath) @@ -378,28 +378,28 @@ class DestinationTest(unittest.TestCase): self.i.artist = '' self.i.albumartist = 'something' self.lib.path_formats = [('default', '$artist')] - p = self.lib.destination(self.i) + p = self.i.destination() self.assertEqual(p.rsplit(os.path.sep, 1)[1], 'something') def test_albumartist_falls_back_to_artist(self): self.i.artist = 'trackartist' self.i.albumartist = '' self.lib.path_formats = [('default', '$albumartist')] - p = self.lib.destination(self.i) + p = self.i.destination() self.assertEqual(p.rsplit(os.path.sep, 1)[1], 'trackartist') def test_artist_overrides_albumartist(self): self.i.artist = 'theartist' self.i.albumartist = 'something' self.lib.path_formats = [('default', '$artist')] - p = self.lib.destination(self.i) + p = self.i.destination() self.assertEqual(p.rsplit(os.path.sep, 1)[1], 'theartist') def test_albumartist_overrides_artist(self): self.i.artist = 'theartist' self.i.albumartist = 'something' self.lib.path_formats = [('default', '$albumartist')] - p = self.lib.destination(self.i) + p = self.i.destination() self.assertEqual(p.rsplit(os.path.sep, 1)[1], 'something') def test_sanitize_path_works_on_empty_string(self): @@ -421,13 +421,13 @@ class DestinationTest(unittest.TestCase): def test_unicode_normalized_nfd_on_mac(self): instr = unicodedata.normalize('NFC', u'caf\xe9') self.lib.path_formats = [('default', instr)] - dest = self.lib.destination(self.i, platform='darwin', fragment=True) + dest = self.i.destination(platform='darwin', fragment=True) self.assertEqual(dest, unicodedata.normalize('NFD', instr)) def test_unicode_normalized_nfc_on_linux(self): instr = unicodedata.normalize('NFD', u'caf\xe9') self.lib.path_formats = [('default', instr)] - dest = self.lib.destination(self.i, platform='linux2', fragment=True) + dest = self.i.destination(platform='linux2', fragment=True) self.assertEqual(dest, unicodedata.normalize('NFC', instr)) def test_non_mbcs_characters_on_windows(self): @@ -436,7 +436,7 @@ class DestinationTest(unittest.TestCase): try: self.i.title = u'h\u0259d' self.lib.path_formats = [('default', '$title')] - p = self.lib.destination(self.i) + p = self.i.destination() self.assertFalse('?' in p) # We use UTF-8 to encode Windows paths now. self.assertTrue(u'h\u0259d'.encode('utf8') in p) @@ -446,7 +446,7 @@ class DestinationTest(unittest.TestCase): def test_unicode_extension_in_fragment(self): self.lib.path_formats = [('default', u'foo')] self.i.path = util.bytestring_path(u'bar.caf\xe9') - dest = self.lib.destination(self.i, platform='linux2', fragment=True) + dest = self.i.destination(platform='linux2', fragment=True) self.assertEqual(dest, u'foo.caf\xe9') class PathFormattingMixin(object): @@ -456,15 +456,14 @@ class PathFormattingMixin(object): def _assert_dest(self, dest, i=None): if i is None: i = self.i - self.assertEqual(self.lib.destination(i, pathmod=posixpath), - dest) + self.assertEqual(i.destination(pathmod=posixpath), dest) class DestinationFunctionTest(unittest.TestCase, PathFormattingMixin): def setUp(self): self.lib = beets.library.Library(':memory:') self.lib.directory = '/base' self.lib.path_formats = [('default', u'path')] - self.i = item() + self.i = item(self.lib) def tearDown(self): self.lib._connection().close() @@ -609,13 +608,13 @@ class PluginDestinationTest(unittest.TestCase): self.lib = beets.library.Library(':memory:') self.lib.directory = '/base' self.lib.path_formats = [('default', u'$artist $foo')] - self.i = item() + self.i = item(self.lib) def tearDown(self): super(PluginDestinationTest, self).tearDown() plugins.template_values = self.old_template_values def _assert_dest(self, dest): - self.assertEqual(self.lib.destination(self.i, pathmod=posixpath), + self.assertEqual(self.i.destination(pathmod=posixpath), '/base/' + dest) def test_undefined_value_not_substituted(self): @@ -764,8 +763,7 @@ class AlbumInfoTest(unittest.TestCase): self.assertEqual(new_ai.artpath, '/my/great/art') def test_albuminfo_for_two_items_doesnt_duplicate_row(self): - i2 = item() - self.lib.add(i2) + i2 = item(self.lib) self.lib.get_album(self.i) self.lib.get_album(i2) @@ -838,8 +836,8 @@ class ArtDestinationTest(_common.TestCase): self.lib = beets.library.Library( ':memory:', replacements=[(re.compile(u'X'), u'Y')] ) - self.i = item() - self.i.path = self.lib.destination(self.i) + self.i = item(self.lib) + self.i.path = self.i.destination() self.ai = self.lib.add_album((self.i,)) def test_art_filename_respects_setting(self): @@ -848,7 +846,7 @@ class ArtDestinationTest(_common.TestCase): def test_art_path_in_item_dir(self): art = self.ai.art_destination('something.jpg') - track = self.lib.destination(self.i) + track = self.i.destination() self.assertEqual(os.path.dirname(art), os.path.dirname(track)) def test_art_path_sanitized(self): @@ -860,8 +858,7 @@ class PathStringTest(_common.TestCase): def setUp(self): super(PathStringTest, self).setUp() self.lib = beets.library.Library(':memory:') - self.i = item() - self.lib.add(self.i) + self.i = item(self.lib) def test_item_path_is_bytestring(self): self.assert_(isinstance(self.i.path, str)) @@ -899,7 +896,7 @@ class PathStringTest(_common.TestCase): def test_destination_returns_bytestring(self): self.i.artist = u'b\xe1r' - dest = self.lib.destination(self.i) + dest = self.i.destination() self.assert_(isinstance(dest, str)) def test_art_destination_returns_bytestring(self): @@ -999,8 +996,7 @@ class ImportTimeTest(_common.TestCase): self.assertGreater(self.track.added, 0) def test_atime_for_singleton(self): - self.singleton = item() - self.lib.add(self.singleton) + self.singleton = item(self.lib) self.assertGreater(self.singleton.added, 0) def suite(): diff --git a/test/test_files.py b/test/test_files.py index 1727f3392..1dee982b7 100644 --- a/test/test_files.py +++ b/test/test_files.py @@ -111,7 +111,7 @@ class MoveTest(_common.TestCase): def test_move_avoids_collision_with_existing_file(self): # Make a conflicting file at the destination. - dest = self.lib.destination(self.i) + dest = self.i.destination() os.makedirs(os.path.dirname(dest)) touch(dest) @@ -157,9 +157,9 @@ class AlbumFileTest(_common.TestCase): [('default', join('$albumartist', '$album', '$title'))] self.libdir = os.path.join(self.temp_dir, 'testlibdir') self.lib.directory = self.libdir - self.i = item() + self.i = item(self.lib) # Make a file for the item. - self.i.path = self.lib.destination(self.i) + self.i.path = self.i.destination() util.mkdirall(self.i.path) touch(self.i.path) # Make an album. @@ -209,8 +209,8 @@ class ArtFileTest(_common.TestCase): self.lib = beets.library.Library(':memory:') self.libdir = os.path.abspath(os.path.join(self.temp_dir, 'testlibdir')) self.lib.directory = self.libdir - self.i = item() - self.i.path = self.lib.destination(self.i) + self.i = item(self.lib) + self.i.path = self.i.destination() # Make a music file. util.mkdirall(self.i.path) touch(self.i.path) @@ -383,8 +383,8 @@ class RemoveTest(_common.TestCase): self.lib = beets.library.Library(':memory:') self.libdir = os.path.abspath(os.path.join(self.temp_dir, 'testlibdir')) self.lib.directory = self.libdir - self.i = item() - self.i.path = self.lib.destination(self.i) + self.i = item(self.lib) + self.i.path = self.i.destination() # Make a music file. util.mkdirall(self.i.path) touch(self.i.path)