diff --git a/beets/library.py b/beets/library.py index 139cdfec0f..d474ed8ab3 100644 --- a/beets/library.py +++ b/beets/library.py @@ -715,6 +715,71 @@ def move(self, copy=False, link=False, basedir=None, with_album=True): # Templating. + def _legalize_partial(self, path, fragment, replacements): + """ Perform a partial legalization of the path (ie. a single + sanitization and truncation). Outputs unicode if fragment is set, + otherwise bytes. + """ + + # Truncate components and remove forbidden characters. + path = util.sanitize_path(path, replacements) + + # Encode for the filesystem. + if not fragment: + path = bytestring_path(path) + + # Preserve extension. + _, extension = os.path.splitext(self.path) + if fragment: + # Outputting Unicode. + extension = extension.decode('utf8', 'ignore') + path += 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._db.directory) + path = util.truncate_path(path, maxlen) + + return path + + def _legalize_path(self, path, fragment): + """ Perform several iterations of _legalize_partial, to generate a + stable, optimal output path. This is necessary for cases where + truncation produces unclean paths (eg. trailing space). + """ + + # Create a list of path candidates + path_candidates = [b''] + + replacements = self._db.replacements + + # Perform an initial pass + path = self._legalize_partial(path, fragment, replacements) + while path != path_candidates[-1]: + # This will keep sanitizing the path until it's stable, or an + # infinite loop appears + while path not in path_candidates: + path_candidates.append(path) + # Convert back to Unicode with extension removed + print(util.displayable_path(path)) + path = os.path.splitext(util.displayable_path(path))[0] + + # Run next pass + path = self._legalize_partial(path, fragment, replacements) + + # If an infinite loop occurred, adjust replacements to avoid it + if path != path_candidates[-1]: + replacements = dict((k, u'_') for k, v in replacements) + + # If there's a rule to match a single underscore, set the + # target to a blank string. + if '_' in replacements: + replacements['_'] = '' + + return path + def destination(self, fragment=False, basedir=None, platform=None, path_formats=None): """Returns the path in the library directory designated for the @@ -763,26 +828,7 @@ def destination(self, fragment=False, basedir=None, platform=None, if beets.config['asciify_paths']: subpath = unidecode(subpath) - # Truncate components and remove forbidden characters. - subpath = util.sanitize_path(subpath, self._db.replacements) - - # Encode for the filesystem. - if not fragment: - subpath = bytestring_path(subpath) - - # Preserve extension. - _, extension = os.path.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._db.directory) - subpath = util.truncate_path(subpath, maxlen) + subpath = self._legalize_path(subpath, fragment) if fragment: return subpath diff --git a/test/test_library.py b/test/test_library.py index c3807637ef..46a6ef1e95 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -452,7 +452,6 @@ def test_destination_with_empty_final_component(self): self.assertEqual(self.i.destination(), np('base/one/_.mp3')) - @unittest.skip('unimplemented: #496') def test_truncation_does_not_conflict_with_replacement(self): # Use a replacement that should always replace the last X in any # path component with a Z.