From 289287690eb84f5d581b342cc6f0a70873aec652 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 1 Nov 2012 15:33:38 -0700 Subject: [PATCH 01/40] more helpful changelog about maxsize --- docs/changelog.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 48de872b3..12fbea45f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -21,8 +21,8 @@ Changelog the `mp3gain`_ or `aacgain`_ command-line tools instead of the failure-prone Gstreamer ReplayGain implementation. Thanks to Fabrice Laporte. * :doc:`/plugins/fetchart` and :doc:`/plugins/embedart`: Both plugins can now - resize album art to avoid excessively large images. Thanks to - Fabrice Laporte. + resize album art to avoid excessively large images. Use the ``maxwidth`` + config option with either plugin. Thanks to Fabrice Laporte. * :doc:`/plugins/scrub`: Scrubbing now removes *all* types of tags from a file rather than just one. For example, if your FLAC file has both ordinary FLAC tags and ID3 tags, the ID3 tags are now also removed. From dfddc3a8995e411ed967f537fc0a769c17b83a08 Mon Sep 17 00:00:00 2001 From: Fabrice Laporte Date: Thu, 1 Nov 2012 23:58:49 +0100 Subject: [PATCH 02/40] Update docs/plugins/fetchart.rst --- docs/plugins/fetchart.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugins/fetchart.rst b/docs/plugins/fetchart.rst index 3f02937e8..518c1eac3 100644 --- a/docs/plugins/fetchart.rst +++ b/docs/plugins/fetchart.rst @@ -53,7 +53,7 @@ art is resized. Server-side resizing can also be slower than local resizing, so consider installing one of the two backends for better performance. When using ImageMagic, beets looks for the ``convert`` executable in your path. -On some versions Windows, the program can be shadowed by a system-provided +On some versions of Windows, the program can be shadowed by a system-provided ``convert.exe``. On these systems, you may need to modify your ``%PATH%`` environment variable so that ImageMagick comes first or use PIL instead. From c569ddd412fa6468f1ff26fd06b8c510fdf392b4 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 3 Nov 2012 12:16:32 -0700 Subject: [PATCH 03/40] human-readable mkdir error --- beets/util/__init__.py | 8 ++++++-- docs/changelog.rst | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 3448d104f..caba758e1 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -95,7 +95,7 @@ class FilesystemError(HumanReadableException): clause = 'while {0} {1} to {2}'.format( self._gerund(), repr(self.paths[0]), repr(self.paths[1]) ) - elif self.verb in ('delete', 'write'): + elif self.verb in ('delete', 'write', 'create'): clause = 'while {0} {1}'.format( self._gerund(), repr(self.paths[0]) ) @@ -185,7 +185,11 @@ def mkdirall(path): """ for ancestor in ancestry(path): if not os.path.isdir(syspath(ancestor)): - os.mkdir(syspath(ancestor)) + try: + os.mkdir(syspath(ancestor)) + except (OSError, IOError) as exc: + raise FilesystemError(exc, 'create', (ancestor,), + traceback.format_exc()) def prune_dirs(path, root=None, clutter=('.DS_Store', 'Thumbs.db')): """If path is an empty directory, then remove it. Recursively remove diff --git a/docs/changelog.rst b/docs/changelog.rst index 12fbea45f..62737a47d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -69,7 +69,8 @@ Changelog * Fix a crash when input is read from a pipe without a specified encoding. * Fix some problem with identifying files on Windows with Unicode directory names in their path. -* Add a human-readable error message when writing files' tags fails. +* Add human-readable error messages when writing files' tags fails or when a + directory can't be created. * Changed plugin loading so that modules can be imported without unintentionally loading the plugins they contain. From 6e889bb6d0f629aec8e492e7b49ddd26e8310b08 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 3 Nov 2012 12:47:35 -0700 Subject: [PATCH 04/40] fix tests for new explicit urlretrieve() call --- test/test_art.py | 40 ++++++++++++++++++---------------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/test/test_art.py b/test/test_art.py index fb9e5258b..b53aae59d 100644 --- a/test/test_art.py +++ b/test/test_art.py @@ -30,24 +30,24 @@ class MockHeaders(object): def gettype(self): return self.typeval class MockUrlRetrieve(object): - def __init__(self, pathval, typeval): + def __init__(self, typeval, pathval='fetched_path'): self.pathval = pathval self.headers = MockHeaders(typeval) self.fetched = None - def __call__(self, url): + def __call__(self, url, filename=None): self.fetched = url - return self.pathval, self.headers + return filename or self.pathval, self.headers class FetchImageTest(unittest.TestCase): def test_invalid_type_returns_none(self): - fetchart.urllib.urlretrieve = MockUrlRetrieve('path', '') + fetchart.urllib.urlretrieve = MockUrlRetrieve('') artpath = fetchart._fetch_image('http://example.com') self.assertEqual(artpath, None) def test_jpeg_type_returns_path(self): - fetchart.urllib.urlretrieve = MockUrlRetrieve('somepath', 'image/jpeg') + fetchart.urllib.urlretrieve = MockUrlRetrieve('image/jpeg') artpath = fetchart._fetch_image('http://example.com') - self.assertEqual(artpath, 'somepath') + self.assertNotEqual(artpath, None) class FSArtTest(unittest.TestCase): def setUp(self): @@ -90,11 +90,10 @@ class CombinedTest(unittest.TestCase): return StringIO.StringIO(self.page_text) def test_main_interface_returns_amazon_art(self): - fetchart.urllib.urlretrieve = \ - MockUrlRetrieve('anotherpath', 'image/jpeg') + fetchart.urllib.urlretrieve = MockUrlRetrieve('image/jpeg') album = _common.Bag(asin='xxxx') artpath = fetchart.art_for_album(album, None) - self.assertEqual(artpath, 'anotherpath') + self.assertNotEqual(artpath, None) def test_main_interface_returns_none_for_missing_asin_and_path(self): album = _common.Bag() @@ -103,43 +102,40 @@ class CombinedTest(unittest.TestCase): def test_main_interface_gives_precedence_to_fs_art(self): _common.touch(os.path.join(self.dpath, 'a.jpg')) - fetchart.urllib.urlretrieve = \ - MockUrlRetrieve('anotherpath', 'image/jpeg') + fetchart.urllib.urlretrieve = MockUrlRetrieve('image/jpeg') album = _common.Bag(asin='xxxx') artpath = fetchart.art_for_album(album, self.dpath) self.assertEqual(artpath, os.path.join(self.dpath, 'a.jpg')) def test_main_interface_falls_back_to_amazon(self): - fetchart.urllib.urlretrieve = \ - MockUrlRetrieve('anotherpath', 'image/jpeg') + fetchart.urllib.urlretrieve = MockUrlRetrieve('image/jpeg') album = _common.Bag(asin='xxxx') artpath = fetchart.art_for_album(album, self.dpath) - self.assertEqual(artpath, 'anotherpath') + self.assertNotEqual(artpath, None) + self.assertFalse(artpath.startswith(self.dpath)) def test_main_interface_tries_amazon_before_aao(self): - fetchart.urllib.urlretrieve = \ - MockUrlRetrieve('anotherpath', 'image/jpeg') + fetchart.urllib.urlretrieve = MockUrlRetrieve('image/jpeg') album = _common.Bag(asin='xxxx') fetchart.art_for_album(album, self.dpath) self.assertFalse(self.urlopen_called) def test_main_interface_falls_back_to_aao(self): - fetchart.urllib.urlretrieve = \ - MockUrlRetrieve('anotherpath', 'text/html') + fetchart.urllib.urlretrieve = MockUrlRetrieve('text/html') album = _common.Bag(asin='xxxx') fetchart.art_for_album(album, self.dpath) self.assertTrue(self.urlopen_called) def test_main_interface_uses_caa_when_mbid_available(self): - mock_retrieve = MockUrlRetrieve('anotherpath', 'image/jpeg') + mock_retrieve = MockUrlRetrieve('image/jpeg') fetchart.urllib.urlretrieve = mock_retrieve album = _common.Bag(mb_albumid='releaseid', asin='xxxx') artpath = fetchart.art_for_album(album, None) - self.assertEqual(artpath, 'anotherpath') + self.assertNotEqual(artpath, None) self.assertTrue('coverartarchive.org' in mock_retrieve.fetched) def test_local_only_does_not_access_network(self): - mock_retrieve = MockUrlRetrieve('anotherpath', 'image/jpeg') + mock_retrieve = MockUrlRetrieve('image/jpeg') fetchart.urllib.urlretrieve = mock_retrieve album = _common.Bag(mb_albumid='releaseid', asin='xxxx') artpath = fetchart.art_for_album(album, self.dpath, local_only=True) @@ -149,7 +145,7 @@ class CombinedTest(unittest.TestCase): def test_local_only_gets_fs_image(self): _common.touch(os.path.join(self.dpath, 'a.jpg')) - mock_retrieve = MockUrlRetrieve('anotherpath', 'image/jpeg') + mock_retrieve = MockUrlRetrieve('image/jpeg') fetchart.urllib.urlretrieve = mock_retrieve album = _common.Bag(mb_albumid='releaseid', asin='xxxx') artpath = fetchart.art_for_album(album, self.dpath, local_only=True) From 19acf0809fd60c55d7e0fa0b3137d757a56cba65 Mon Sep 17 00:00:00 2001 From: Jakob Schnitzer Date: Mon, 5 Nov 2012 22:30:21 +0100 Subject: [PATCH 05/40] convert: switch from flac and lame to ffmpeg Instead of flac and lame the convert plugin now uses ffmpeg. This adds support for more input formats and simplifies the code. ffmpeg also uses the lame encoder internally and has equivalents of all the -V presets which should be sufficient. --- beetsplug/convert.py | 34 +++++++--------------------------- docs/plugins/convert.rst | 23 ++++++++++++----------- 2 files changed, 19 insertions(+), 38 deletions(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 2afa658a7..b9259d2fb 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -32,41 +32,22 @@ _fs_lock = threading.Lock() def encode(source, dest): log.info(u'Started encoding {0}'.format(util.displayable_path(source))) - temp_dest = dest + '~' - source_ext = os.path.splitext(source)[1].lower() - if source_ext == '.flac': - decode = Popen([conf['flac'], '-c', '-d', '-s', source], - stdout=PIPE) - encode = Popen([conf['lame']] + conf['opts'] + ['-', temp_dest], - stdin=decode.stdout, stderr=DEVNULL) - decode.stdout.close() - encode.communicate() - elif source_ext == '.mp3': - encode = Popen([conf['lame']] + conf['opts'] + ['--mp3input'] + - [source, temp_dest], close_fds=True, stderr=DEVNULL) - encode.communicate() - else: - log.error(u'Only converting from FLAC or MP3 implemented') - return + encode = Popen([conf['ffmpeg']] + ['-i', source] + conf['opts'] + + [dest], close_fds=True, stderr=DEVNULL) + encode.wait() if encode.returncode != 0: # Something went wrong (probably Ctrl+C), remove temporary files log.info(u'Encoding {0} failed. Cleaning up...'.format(source)) - util.remove(temp_dest) - util.prune_dirs(os.path.dirname(temp_dest)) + util.remove(dest) + util.prune_dirs(os.path.dirname(dest)) return - shutil.move(temp_dest, dest) log.info(u'Finished encoding {0}'.format(util.displayable_path(source))) def convert_item(lib, dest_dir): while True: item = yield - if item.format != 'FLAC' and item.format != 'MP3': - log.info(u'Skipping {0} (unsupported format)'.format( - util.displayable_path(item.path) - )) - continue dest = os.path.join(dest_dir, lib.destination(item, fragment=True)) dest = os.path.splitext(dest)[0] + '.mp3' @@ -122,10 +103,9 @@ class ConvertPlugin(BeetsPlugin): conf['dest'] = ui.config_val(config, 'convert', 'dest', None) conf['threads'] = int(ui.config_val(config, 'convert', 'threads', util.cpu_count())) - conf['flac'] = ui.config_val(config, 'convert', 'flac', 'flac') - conf['lame'] = ui.config_val(config, 'convert', 'lame', 'lame') + conf['ffmpeg'] = ui.config_val(config, 'convert', 'ffmpeg', 'ffmpeg') conf['opts'] = ui.config_val(config, 'convert', - 'opts', '-V2').split(' ') + 'opts', '-aq 2').split(' ') conf['max_bitrate'] = int(ui.config_val(config, 'convert', 'max_bitrate', '500')) conf['embed'] = ui.config_val(config, 'convert', 'embed', True, diff --git a/docs/plugins/convert.rst b/docs/plugins/convert.rst index 47a4d5621..d679d56d9 100644 --- a/docs/plugins/convert.rst +++ b/docs/plugins/convert.rst @@ -2,7 +2,7 @@ Convert Plugin ============== The ``convert`` plugin lets you convert parts of your collection to a directory -of your choice. Currently only converting from MP3 or FLAC to MP3 is supported. +of your choice. It converts all input formats supported by ffmpeg to MP3. It will skip files that are already present in the target directory. Converted files follow the same path formats as your library. @@ -11,13 +11,12 @@ Installation First, enable the ``convert`` plugin (see :doc:`/plugins/index`). -To transcode music, this plugin requires the ``flac`` and ``lame`` command-line -tools. If those executables are in your path, they will be found automatically -by the plugin. Otherwise, configure the plugin to locate the executables:: +To transcode music, this plugin requires the ``ffmpeg`` command-line +tool. If its executable is in your path, it will be found automatically +by the plugin. Otherwise, configure the plugin to locate the executable:: [convert] - flac: /usr/bin/flac - lame: /usr/bin/lame + ffmpeg: /usr/bin/ffmpeg Usage ----- @@ -44,10 +43,12 @@ The plugin offers several configuration options, all of which live under the * If you set ``max_bitrate``, all MP3 files with a higher bitrate will be transcoded and those with a lower bitrate will simply be copied. Note that this does not guarantee that all converted files will have a lower - bitrate---that depends on the encoder and its configuration. By default, FLAC - files will be converted and all MP3s will be copied without transcoding. -* ``opts`` are the encoding options that are passed to ``lame``. Default: - "-V2". Please refer to the LAME documentation for possible options. + bitrate---that depends on the encoder and its configuration. By default MP3s + will be copied without transcoding and all other formats will be converted. +* ``opts`` are the encoding options that are passed to ``ffmpeg``. Default: + "-aq 2". "-aq " is equivalent to the LAME option "-V ". If you + want to specify a bitrate use "-ab ". Please refer to the FFMPEG + documentation for more details. * Finally, ``threads`` determines the number of threads to use for parallel encoding. By default, the plugin will detect the number of processors available and use them all. @@ -57,6 +58,6 @@ Here's an example configuration:: [convert] embed: false max_bitrate: 200 - opts: -V4 + opts: -aq 4 dest: /home/user/MusicForPhone threads: 4 From ffe5d37d785357d071a256ef43a9c25a0c5cbc32 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 6 Nov 2012 14:22:36 -0800 Subject: [PATCH 06/40] log unicode string literals (GC-456) --- beetsplug/fetchart.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index 567d8a64b..57299dca9 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -47,7 +47,7 @@ def _fetch_image(url): try: _, headers = urllib.urlretrieve(url, filename=fn) except IOError: - log.debug('error fetching art') + log.debug(u'error fetching art') return # Make sure it's actually an image. @@ -105,7 +105,7 @@ def aao_art(asin): image_url = m.group(1) return image_url else: - log.debug('fetchart: no image found on page') + log.debug(u'fetchart: no image found on page') # Art from the filesystem. @@ -126,14 +126,14 @@ def art_in_path(path): for fn in images: for name in COVER_NAMES: if fn.lower().startswith(name): - log.debug('fetchart: using well-named art file {0}'.format( + log.debug(u'fetchart: using well-named art file {0}'.format( util.displayable_path(fn) )) return os.path.join(path, fn) # Fall back to any image in the folder. if images: - log.debug('fetchart: using fallback art file {0}'.format( + log.debug(u'fetchart: using fallback art file {0}'.format( util.displayable_path(images[0]) )) return os.path.join(path, images[0]) From 859268f70737174bcf10846339eda2e272fa19b5 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 6 Nov 2012 14:29:58 -0800 Subject: [PATCH 07/40] decode Unicode arguments to import -L (GC-457) --- beets/ui/commands.py | 2 +- docs/changelog.rst | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 24d7280e7..9a2d05dc5 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -795,7 +795,7 @@ def import_func(lib, config, opts, args): quiet_fallback = importer.action.SKIP if opts.library: - query = args + query = decargs(args) paths = [] else: query = None diff --git a/docs/changelog.rst b/docs/changelog.rst index 62737a47d..8fa2ea9a5 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -69,6 +69,7 @@ Changelog * Fix a crash when input is read from a pipe without a specified encoding. * Fix some problem with identifying files on Windows with Unicode directory names in their path. +* Fix a crash when Unicode queries were used with ``import -L`` re-imports. * Add human-readable error messages when writing files' tags fails or when a directory can't be created. * Changed plugin loading so that modules can be imported without From 90f7fabb9a7015f0458f1383b69947029036cdb7 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 6 Nov 2012 14:38:45 -0800 Subject: [PATCH 08/40] convert docs phrasing tweaks @yagebu: I did a code review of the new version of convert using FFmpeg as a backend. Everything looks perfect. These are just a few changes to the docs. Thanks again! --- docs/plugins/convert.rst | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/plugins/convert.rst b/docs/plugins/convert.rst index d679d56d9..37345bb75 100644 --- a/docs/plugins/convert.rst +++ b/docs/plugins/convert.rst @@ -2,10 +2,12 @@ Convert Plugin ============== The ``convert`` plugin lets you convert parts of your collection to a directory -of your choice. It converts all input formats supported by ffmpeg to MP3. +of your choice. It converts all input formats supported by `FFmpeg`_ to MP3. It will skip files that are already present in the target directory. Converted files follow the same path formats as your library. +.. _FFmpeg: http://ffmpeg.org + Installation ------------ @@ -46,9 +48,9 @@ The plugin offers several configuration options, all of which live under the bitrate---that depends on the encoder and its configuration. By default MP3s will be copied without transcoding and all other formats will be converted. * ``opts`` are the encoding options that are passed to ``ffmpeg``. Default: - "-aq 2". "-aq " is equivalent to the LAME option "-V ". If you - want to specify a bitrate use "-ab ". Please refer to the FFMPEG - documentation for more details. + "-aq 2". (Note that "-aq " is equivalent to the LAME option "-V + ".) If you want to specify a bitrate, use "-ab ". Refer to the + `FFmpeg`_ documentation for more details. * Finally, ``threads`` determines the number of threads to use for parallel encoding. By default, the plugin will detect the number of processors available and use them all. From 729a89cff399701086e88b025d510e3202a8b1ad Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Fri, 9 Nov 2012 00:01:36 -0800 Subject: [PATCH 09/40] lyrics: possibly address a Unicode error --- beetsplug/lyrics.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index 2a801436e..eadb5d6b6 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -51,11 +51,11 @@ def unescape(text): """Resolves &#xxx; HTML entities (and some others).""" if isinstance(text, str): text = text.decode('utf8', 'ignore') - out = text.replace(' ', ' ') + out = text.replace(u' ', u' ') def replchar(m): num = m.group(1) return unichr(int(num)) - out = re.sub("&#(\d+);", replchar, out) + out = re.sub(u"&#(\d+);", replchar, out) return out def extract_text(html, starttag): From ad9021b9d9af1c636a6c787f13b95a38b42fcd2e Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Fri, 9 Nov 2012 16:25:19 -0800 Subject: [PATCH 10/40] replaygain: normalize peaks to 2^15 (closes #65) --- beetsplug/replaygain.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index f7e041634..06fd02301 100755 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -24,6 +24,7 @@ from beets.ui import commands log = logging.getLogger('beets') DEFAULT_REFERENCE_LOUDNESS = 89 +SAMPLE_MAX = 1 << 15 class ReplayGainError(Exception): """Raised when an error occurs during mp3gain/aacgain execution. @@ -54,7 +55,7 @@ def parse_tool_output(text): 'file': parts[0], 'mp3gain': int(parts[1]), 'gain': float(parts[2]), - 'peak': float(parts[3]), + 'peak': float(parts[3]) / SAMPLE_MAX, 'maxgain': int(parts[4]), 'mingain': int(parts[5]), }) @@ -189,7 +190,7 @@ class ReplayGainPlugin(BeetsPlugin): # Adjust to avoid clipping. cmd = cmd + ['-k'] else: - # Disable clipping warning. + # Disable clipping warning. cmd = cmd + ['-c'] if self.apply_gain: # Lossless audio adjustment. @@ -204,7 +205,7 @@ class ReplayGainPlugin(BeetsPlugin): return results - def store_gain(self, lib, items, rgain_infos, album=None): + def store_gain(self, lib, items, rgain_infos, album=None): """Store computed ReplayGain values to the Items and the Album (if it is provided). """ From 927759fc007d53ccad96d49c644a33360b646384 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 27 Nov 2012 15:53:54 -0800 Subject: [PATCH 11/40] readme: use default-version RTD links --- README.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 8780e95a8..dc28d6d7e 100644 --- a/README.rst +++ b/README.rst @@ -31,11 +31,11 @@ imagine for your music collection. Via `plugins`_, beets becomes a panacea: If beets doesn't do what you want yet, `writing your own plugin`_ is shockingly simple if you know a little Python. -.. _plugins: http://beets.readthedocs.org/en/latest/plugins/ +.. _plugins: http://beets.readthedocs.org/page/plugins/ .. _MPD: http://mpd.wikia.com/ .. _MusicBrainz music collection: http://musicbrainz.org/show/collection/ .. _writing your own plugin: - http://beets.readthedocs.org/en/latest/plugins/#writing-plugins + http://beets.readthedocs.org/page/plugins/#writing-plugins .. _HTML5 Audio: http://www.w3.org/TR/html-markup/audio.html @@ -50,7 +50,7 @@ cutting edge, type ``pip install beets==dev`` for the `latest source`_.) Check out the `Getting Started`_ guide to learn more about installing and using beets. .. _its Web site: http://beets.radbox.org/ -.. _Getting Started: http://beets.readthedocs.org/en/latest/guides/main.html +.. _Getting Started: http://beets.readthedocs.org/page/guides/main.html .. _@b33ts: http://twitter.com/b33ts/ .. _latest source: https://github.com/sampsyo/beets/tarball/master#egg=beets-dev From da3ffcbec3e2ddb2be6b2466a5a319889f6b7d77 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 27 Nov 2012 15:56:02 -0800 Subject: [PATCH 12/40] version change: b16 is now 1.0rc1 --- beets/__init__.py | 2 +- docs/changelog.rst | 2 +- docs/conf.py | 4 ++-- setup.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/beets/__init__.py b/beets/__init__.py index e0fa5a025..933b6afce 100644 --- a/beets/__init__.py +++ b/beets/__init__.py @@ -12,7 +12,7 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -__version__ = '1.0b16' +__version__ = '1.0rc1' __author__ = 'Adrian Sampson ' import beets.library diff --git a/docs/changelog.rst b/docs/changelog.rst index 8fa2ea9a5..910b9806c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,7 +1,7 @@ Changelog ========= -1.0b16 (in development) +1.0rc1 (in development) ----------------------- * New plugin: :doc:`/plugins/convert` transcodes music and embeds album art diff --git a/docs/conf.py b/docs/conf.py index 95cd09e47..09790f717 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,8 +12,8 @@ master_doc = 'index' project = u'beets' copyright = u'2012, Adrian Sampson' -version = '1.0b16' -release = '1.0b16' +version = '1.0rc1' +release = '1.0rc1' pygments_style = 'sphinx' diff --git a/setup.py b/setup.py index 41e74e4f0..f19636b9a 100755 --- a/setup.py +++ b/setup.py @@ -42,7 +42,7 @@ if 'sdist' in sys.argv: shutil.copytree(os.path.join(docdir, '_build', 'man'), mandir) setup(name='beets', - version='1.0b16', + version='1.0rc1', description='music tagger and library organizer', author='Adrian Sampson', author_email='adrian@radbox.org', From 953291f7366e00a0e473c458e56f20794b86a284 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 27 Nov 2012 16:43:57 -0800 Subject: [PATCH 13/40] truncate bytes instead of unicode (GC-422) --- beets/library.py | 1 + beets/util/__init__.py | 13 ++++++++----- docs/changelog.rst | 2 ++ 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/beets/library.py b/beets/library.py index caf132ce4..12e16e07c 100644 --- a/beets/library.py +++ b/beets/library.py @@ -1152,6 +1152,7 @@ class Library(BaseLibrary): # Encode for the filesystem. if not fragment: subpath = bytestring_path(subpath) + subpath = util.truncate_path(subpath, pathmod) # Preserve extension. _, extension = pathmod.splitext(item.path) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index caba758e1..2b76a5dd0 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -452,16 +452,19 @@ def sanitize_path(path, pathmod=None, replacements=None): if not comps: return '' for i, comp in enumerate(comps): - # Replace special characters. for regex, repl in replacements: comp = regex.sub(repl, comp) - - # Truncate each component. - comp = comp[:MAX_FILENAME_LENGTH] - comps[i] = comp return pathmod.join(*comps) +def truncate_path(path, pathmod=None): + """Given a bytestring path or a Unicode path fragment, truncate the + components to a legal length. + """ + pathmod = pathmod or os.path + comps = [c[:MAX_FILENAME_LENGTH] for c in components(path, pathmod)] + return pathmod.join(*comps) + def sanitize_for_path(value, pathmod, key=None): """Sanitize the value for inclusion in a path: replace separators with _, etc. Doesn't guarantee that the whole path will be valid; diff --git a/docs/changelog.rst b/docs/changelog.rst index 910b9806c..76c3daf37 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -58,6 +58,8 @@ Changelog * Add the track mapping dictionary to the ``album_distance`` plugin function. * When an exception is raised while reading a file, the path of the file in question is now logged (thanks to Mike Kazantsev). +* Truncate long filenames based on their *bytes* rather than their Unicode + *characters*, fixing situations where encoded names could be too long. * Fix an assertion failure when the MusicBrainz main database and search server disagree. * Fix a bug that caused the :doc:`/plugins/lastgenre` and other plugins not to From eef87c21899a4f056c8cab6809f9f9ea761da243 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 27 Nov 2012 16:54:50 -0800 Subject: [PATCH 14/40] truncation incorporates extension length (GC-461) --- beets/library.py | 4 +++- beets/util/__init__.py | 17 +++++++++++++---- docs/changelog.rst | 1 + test/test_db.py | 13 +++++++++++++ 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/beets/library.py b/beets/library.py index 12e16e07c..f8a70fe2a 100644 --- a/beets/library.py +++ b/beets/library.py @@ -1152,7 +1152,6 @@ class Library(BaseLibrary): # Encode for the filesystem. if not fragment: subpath = bytestring_path(subpath) - subpath = util.truncate_path(subpath, pathmod) # Preserve extension. _, extension = pathmod.splitext(item.path) @@ -1161,6 +1160,9 @@ class Library(BaseLibrary): extension = extension.decode('utf8', 'ignore') subpath += extension.lower() + # Truncate too-long components. + subpath = util.truncate_path(subpath, pathmod) + if fragment: return subpath else: diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 2b76a5dd0..d798cb102 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -457,13 +457,22 @@ def sanitize_path(path, pathmod=None, replacements=None): comps[i] = comp return pathmod.join(*comps) -def truncate_path(path, pathmod=None): +def truncate_path(path, pathmod=None, length=MAX_FILENAME_LENGTH): """Given a bytestring path or a Unicode path fragment, truncate the - components to a legal length. + components to a legal length. In the last component, the extension + is preserved. """ pathmod = pathmod or os.path - comps = [c[:MAX_FILENAME_LENGTH] for c in components(path, pathmod)] - return pathmod.join(*comps) + comps = components(path, pathmod) + + out = [c[:length] for c in comps] + base, ext = pathmod.splitext(comps[-1]) + if ext: + # Last component has an extension. + base = base[:length - len(ext)] + out[-1] = base + ext + + return pathmod.join(*out) def sanitize_for_path(value, pathmod, key=None): """Sanitize the value for inclusion in a path: replace separators diff --git a/docs/changelog.rst b/docs/changelog.rst index 76c3daf37..d65326b65 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -60,6 +60,7 @@ Changelog question is now logged (thanks to Mike Kazantsev). * Truncate long filenames based on their *bytes* rather than their Unicode *characters*, fixing situations where encoded names could be too long. +* Filename truncation now incorporates the length of the extension. * Fix an assertion failure when the MusicBrainz main database and search server disagree. * Fix a bug that caused the :doc:`/plugins/lastgenre` and other plugins not to diff --git a/test/test_db.py b/test/test_db.py index 5df34f45e..dc1d0ce0f 100644 --- a/test/test_db.py +++ b/test/test_db.py @@ -918,6 +918,19 @@ class PathStringTest(unittest.TestCase): alb = self.lib.get_album(alb.id) self.assert_(isinstance(alb.artpath, str)) +class PathTruncationTest(unittest.TestCase): + def test_truncate_bytestring(self): + p = util.truncate_path('abcde/fgh', posixpath, 4) + self.assertEqual(p, 'abcd/fgh') + + def test_truncate_unicode(self): + p = util.truncate_path(u'abcde/fgh', posixpath, 4) + self.assertEqual(p, u'abcd/fgh') + + def test_truncate_preserves_extension(self): + p = util.truncate_path(u'abcde/fgh.ext', posixpath, 5) + self.assertEqual(p, u'abcde/f.ext') + class MtimeTest(unittest.TestCase): def setUp(self): self.ipath = os.path.join(_common.RSRC, 'testfile.mp3') From 9a4cda60cddad061b1d149fe507f0e93b5b8f793 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 27 Nov 2012 17:05:56 -0800 Subject: [PATCH 15/40] autotag: use albumartist if available (GC-423) --- beets/autotag/match.py | 11 ++++++++--- docs/changelog.rst | 2 ++ test/test_autotag.py | 11 +++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/beets/autotag/match.py b/beets/autotag/match.py index a0121f293..f1e430168 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -166,14 +166,19 @@ def current_metadata(items): """Returns the most likely artist and album for a set of Items. Each is determined by tag reflected by the plurality of the Items. """ - keys = 'artist', 'album' likelies = {} consensus = {} - for key in keys: + for key in 'artist', 'album', 'albumartist': values = [getattr(item, key) for item in items if item] likelies[key], freq = plurality(values) consensus[key] = (freq == len(values)) - return likelies['artist'], likelies['album'], consensus['artist'] + + if consensus['albumartist'] and likelies['albumartist']: + artist = likelies['albumartist'] + else: + artist = likelies['artist'] + + return artist, likelies['album'], consensus['artist'] def assign_items(items, tracks): """Given a list of Items and a list of TrackInfo objects, find the diff --git a/docs/changelog.rst b/docs/changelog.rst index d65326b65..797f216af 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -37,6 +37,8 @@ Changelog * Null values in the database can now be matched with the empty-string regular expression, ``^$``. * Queries now correctly match non-string values in path format predicates. +* When autotagging a various-artists album, the album artist field is now + used instead of the majority track artist. * :doc:`/plugins/lastgenre`: Use the albums' existing genre tags if they pass the whitelist (thanks to Fabrice Laporte). * :doc:`/plugins/lastgenre`: Add a ``lastgenre`` command for fetching genres diff --git a/test/test_autotag.py b/test/test_autotag.py index a07bad33c..be331370f 100644 --- a/test/test_autotag.py +++ b/test/test_autotag.py @@ -68,6 +68,17 @@ class PluralityTest(unittest.TestCase): self.assertEqual(l_album, 'The White Album') self.assertTrue(artist_consensus) + def test_albumartist_consensus(self): + items = [Item({'artist': 'tartist1', 'album': 'album', + 'albumartist': 'aartist'}), + Item({'artist': 'tartist2', 'album': 'album', + 'albumartist': 'aartist'}), + Item({'artist': 'tartist3', 'album': 'album', + 'albumartist': 'aartist'})] + l_artist, l_album, artist_consensus = match.current_metadata(items) + self.assertEqual(l_artist, 'aartist') + self.assertFalse(artist_consensus) + def _make_item(title, track, artist='some artist'): return Item({ 'title': title, 'track': track, From becd4f957d1a525848570cbea64f96dd455160a4 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 27 Nov 2012 19:44:46 -0800 Subject: [PATCH 16/40] long-overdue docs for the "info" plugin --- docs/plugins/index.rst | 2 ++ docs/plugins/info.rst | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 docs/plugins/info.rst diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index cd94cf104..b608245ba 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -55,6 +55,7 @@ disabled by default, but you can turn them on as described above. zero ihate convert + info Autotagger Extensions '''''''''''''''''''''' @@ -98,6 +99,7 @@ Miscellaneous * :doc:`bpd`: A music player for your beets library that emulates `MPD`_ and is compatible with `MPD clients`_. * :doc:`convert`: Converts parts of your collection to an external directory +* :doc:`info`: Print music files' tags to the console. .. _MPD: http://mpd.wikia.com/ .. _MPD clients: http://mpd.wikia.com/wiki/Clients diff --git a/docs/plugins/info.rst b/docs/plugins/info.rst new file mode 100644 index 000000000..38ee40085 --- /dev/null +++ b/docs/plugins/info.rst @@ -0,0 +1,16 @@ +Info Plugin +=========== + +The ``info`` plugin provides a command that dumps the current tag values for +any file format supported by beets. It works like a supercharged version of +`mp3info`_ or `id3v2`_. + +Enable the plugin and then type:: + + $ beet info /path/to/music.flac + +and the plugin will enumerate all the tags in the specified file. It also +accepts multiple filenames in a single command-line. + +.. _id3v2: http://id3v2.sourceforge.net +.. _mp3info: http://www.ibiblio.org/mp3info/ From 56a49b1374a241625ae4697b76f81cfc2026eafe Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 27 Nov 2012 19:50:35 -0800 Subject: [PATCH 17/40] avoid potential error with malformed dates from MB --- beets/autotag/mb.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/beets/autotag/mb.py b/beets/autotag/mb.py index 5734fbc56..7aaa6595f 100644 --- a/beets/autotag/mb.py +++ b/beets/autotag/mb.py @@ -131,7 +131,12 @@ def _set_date_str(info, date_str): date_parts = date_str.split('-') for key in ('year', 'month', 'day'): if date_parts: - setattr(info, key, int(date_parts.pop(0))) + date_part = date_parts.pop(0) + try: + date_num = int(date_part) + except ValueError: + continue + setattr(info, key, date_num) def album_info(release): """Takes a MusicBrainz release result dictionary and returns a beets From 0a21d72a79e02ef3843f0ec3ad7e64383f545b9f Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 27 Nov 2012 20:38:47 -0800 Subject: [PATCH 18/40] tolerate non-UTF8 locale in test --- test/test_ui.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/test_ui.py b/test/test_ui.py index eea86adf8..15d52175a 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -704,8 +704,9 @@ class ShowChangeTest(unittest.TestCase): def test_item_data_change_title_missing_with_unicode_filename(self): self.items[0].title = '' self.items[0].path = u'/path/to/caf\xe9.mp3'.encode('utf8') - msg = self._show_change() - self.assertTrue(u'caf\xe9.mp3 -> the title' in msg.decode('utf8')) + msg = self._show_change().decode('utf8') + self.assertTrue(u'caf\xe9.mp3 -> the title' in msg + or u'caf.mp3 ->' in msg) class DefaultPathTest(unittest.TestCase): def setUp(self): From f06c7dfbef30e66401279e0922f0f76f12edf684 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 27 Nov 2012 21:42:36 -0800 Subject: [PATCH 19/40] modernize basic MediaFile tests These tests were written when I knew almost nothing about Python and even less about unittest. The class-generating magic never worked with nose for a crazy reason I won't get into here. This has a bit more copypasta but the workings are more obvious and we no longer generate enormous numbers of independent tests. There should be a more representative number of dots in the test runner output now. --- test/test_mediafile_basic.py | 364 +++++++++++++++++++---------------- 1 file changed, 200 insertions(+), 164 deletions(-) diff --git a/test/test_mediafile_basic.py b/test/test_mediafile_basic.py index b05693f0d..243fcd702 100644 --- a/test/test_mediafile_basic.py +++ b/test/test_mediafile_basic.py @@ -23,128 +23,7 @@ import _common from _common import unittest import beets.mediafile - -def MakeReadingTest(path, correct_dict, field): - class ReadingTest(unittest.TestCase): - def setUp(self): - self.f = beets.mediafile.MediaFile(path) - def runTest(self): - got = getattr(self.f, field) - correct = correct_dict[field] - message = field + ' incorrect (expected ' + repr(correct) + \ - ', got ' + repr(got) + ') when testing ' + \ - os.path.basename(path) - if isinstance(correct, float): - self.assertAlmostEqual(got, correct, msg=message) - else: - self.assertEqual(got, correct, message) - return ReadingTest - -def MakeReadOnlyTest(path, field, value): - class ReadOnlyTest(unittest.TestCase): - def setUp(self): - self.f = beets.mediafile.MediaFile(path) - def runTest(self): - got = getattr(self.f, field) - fail_msg = field + ' incorrect (expected ' + \ - repr(value) + ', got ' + repr(got) + \ - ') on ' + os.path.basename(path) - if field == 'length': - self.assertTrue(value-0.1 < got < value+0.1, fail_msg) - else: - self.assertEqual(got, value, fail_msg) - return ReadOnlyTest - -def MakeWritingTest(path, correct_dict, field, testsuffix='_test'): - - class WritingTest(unittest.TestCase): - def setUp(self): - # make a copy of the file we'll work on - root, ext = os.path.splitext(path) - self.tpath = root + testsuffix + ext - shutil.copy(path, self.tpath) - - # generate the new value we'll try storing - if field == 'art': - self.value = 'xxx' - elif type(correct_dict[field]) is unicode: - self.value = u'TestValue: ' + field - elif type(correct_dict[field]) is int: - self.value = correct_dict[field] + 42 - elif type(correct_dict[field]) is bool: - self.value = not correct_dict[field] - elif type(correct_dict[field]) is datetime.date: - self.value = correct_dict[field] + datetime.timedelta(42) - elif type(correct_dict[field]) is str: - self.value = 'TestValue-' + str(field) - elif type(correct_dict[field]) is float: - self.value = 9.87 - else: - raise ValueError('unknown field type ' + \ - str(type(correct_dict[field]))) - - def runTest(self): - # write new tag - a = beets.mediafile.MediaFile(self.tpath) - setattr(a, field, self.value) - a.save() - - # verify ALL tags are correct with modification - b = beets.mediafile.MediaFile(self.tpath) - for readfield in correct_dict.keys(): - got = getattr(b, readfield) - - # Make sure the modified field was changed correctly... - if readfield == field: - message = field + ' modified incorrectly (changed to ' + \ - repr(self.value) + ' but read ' + repr(got) + \ - ') when testing ' + os.path.basename(path) - if isinstance(self.value, float): - self.assertAlmostEqual(got, self.value, msg=message) - else: - self.assertEqual(got, self.value, message) - - # ... and that no other field was changed. - else: - # MPEG-4: ReplayGain not implented. - if 'm4a' in path and readfield.startswith('rg_'): - continue - - # The value should be what it was originally most of the - # time. - correct = correct_dict[readfield] - - # The date field, however, is modified when its components - # change. - if readfield=='date' and field in ('year', 'month', 'day'): - try: - correct = datetime.date( - self.value if field=='year' else correct.year, - self.value if field=='month' else correct.month, - self.value if field=='day' else correct.day - ) - except ValueError: - correct = datetime.date.min - # And vice-versa. - if field=='date' and readfield in ('year', 'month', 'day'): - correct = getattr(self.value, readfield) - - message = readfield + ' changed when it should not have' \ - ' (expected ' + repr(correct) + ', got ' + \ - repr(got) + ') when modifying ' + field + \ - ' in ' + os.path.basename(path) - if isinstance(correct, float): - self.assertAlmostEqual(got, correct, msg=message) - else: - self.assertEqual(got, correct, message) - - def tearDown(self): - if os.path.exists(self.tpath): - os.remove(self.tpath) - - return WritingTest - -correct_dicts = { +CORRECT_DICTS = { # All of the fields iTunes supports that we do also. 'full': { @@ -253,7 +132,7 @@ correct_dicts = { } -read_only_correct_dicts = { +READ_ONLY_CORRECT_DICTS = { 'full.mp3': { 'length': 1.0, 'bitrate': 80000, @@ -318,21 +197,7 @@ read_only_correct_dicts = { }, } -def suite_for_file(path, correct_dict, writing=True): - s = unittest.TestSuite() - for field in correct_dict: - if 'm4a' in path and field.startswith('rg_'): - # MPEG-4 files: ReplayGain values not implemented. - continue - s.addTest(MakeReadingTest(path, correct_dict, field)()) - if writing and \ - not ( field == 'month' and correct_dict['year'] == 0 - or field == 'day' and correct_dict['month'] == 0): - # ensure that we don't test fields that can't be modified - s.addTest(MakeWritingTest(path, correct_dict, field)()) - return s - -test_files = { +TEST_FILES = { 'm4a': ['full', 'partial', 'min'], 'mp3': ['full', 'partial', 'min'], 'flac': ['full', 'partial', 'min'], @@ -342,39 +207,210 @@ test_files = { 'mpc': ['full'], } -def suite(): - s = unittest.TestSuite() +class AllFilesMixin(object): + """This is a dumb bit of copypasta but unittest has no supported + method of generating tests at runtime. + """ + def test_m4a_full(self): + self._run('full', 'm4a') - # General tests. - for kind, tagsets in test_files.items(): - for tagset in tagsets: - path = os.path.join(_common.RSRC, tagset + '.' + kind) - correct_dict = correct_dicts[tagset] - for test in suite_for_file(path, correct_dict): - s.addTest(test) + def test_m4a_partial(self): + self._run('partial', 'm4a') - # Special test for missing ID3 tag. - for test in suite_for_file(os.path.join(_common.RSRC, 'empty.mp3'), - correct_dicts['empty'], - writing=False): - s.addTest(test) + def test_m4a_min(self): + self._run('min', 'm4a') + + def test_mp3_full(self): + self._run('full', 'mp3') + + def test_mp3_partial(self): + self._run('partial', 'mp3') + + def test_mp3_min(self): + self._run('min', 'mp3') + + def test_flac_full(self): + self._run('full', 'flac') + + def test_flac_partial(self): + self._run('partial', 'flac') + + def test_flac_min(self): + self._run('min', 'flac') + + def test_ogg(self): + self._run('full', 'ogg') + + def test_ape(self): + self._run('full', 'ape') + + def test_wv(self): + self._run('full', 'wv') + + def test_mpc(self): + self._run('full', 'mpc') # Special test for advanced release date. - for test in suite_for_file(os.path.join(_common.RSRC, 'date.mp3'), - correct_dicts['date']): - s.addTest(test) + def test_date_mp3(self): + self._run('date', 'mp3') - # Read-only attribute tests. - for fname, correct_dict in read_only_correct_dicts.iteritems(): - path = os.path.join(_common.RSRC, fname) - for field, value in correct_dict.iteritems(): - s.addTest(MakeReadOnlyTest(path, field, value)()) +class ReadingTest(unittest.TestCase, AllFilesMixin): + def _read_field(self, mf, correct_dict, field): + got = getattr(mf, field) + correct = correct_dict[field] + message = field + ' incorrect (expected ' + repr(correct) + \ + ', got ' + repr(got) + ')' + if isinstance(correct, float): + self.assertAlmostEqual(got, correct, msg=message) + else: + self.assertEqual(got, correct, message) + + def _run(self, tagset, kind): + correct_dict = CORRECT_DICTS[tagset] + path = os.path.join(_common.RSRC, tagset + '.' + kind) + f = beets.mediafile.MediaFile(path) + for field in correct_dict: + if 'm4a' in path and field.startswith('rg_'): + # MPEG-4 files: ReplayGain values not implemented. + continue + self._read_field(f, correct_dict, field) - return s + # Special test for missing ID3 tag. + def test_empy_mp3(self): + self._run('empty', 'mp3') -def test_nose_suite(): - for test in suite(): - yield test +class WritingTest(unittest.TestCase, AllFilesMixin): + def _write_field(self, tpath, field, value, correct_dict): + # Write new tag. + a = beets.mediafile.MediaFile(tpath) + setattr(a, field, value) + a.save() + + # Verify ALL tags are correct with modification. + b = beets.mediafile.MediaFile(tpath) + for readfield in correct_dict.keys(): + got = getattr(b, readfield) + + # Make sure the modified field was changed correctly... + if readfield == field: + message = field + ' modified incorrectly (changed to ' + \ + repr(value) + ' but read ' + repr(got) + ')' + if isinstance(value, float): + self.assertAlmostEqual(got, value, msg=message) + else: + self.assertEqual(got, value, message) + + # ... and that no other field was changed. + else: + # MPEG-4: ReplayGain not implented. + if 'm4a' in tpath and readfield.startswith('rg_'): + continue + + # The value should be what it was originally most of the + # time. + correct = correct_dict[readfield] + + # The date field, however, is modified when its components + # change. + if readfield=='date' and field in ('year', 'month', 'day'): + try: + correct = datetime.date( + value if field=='year' else correct.year, + value if field=='month' else correct.month, + value if field=='day' else correct.day + ) + except ValueError: + correct = datetime.date.min + # And vice-versa. + if field=='date' and readfield in ('year', 'month', 'day'): + correct = getattr(value, readfield) + + message = readfield + ' changed when it should not have' \ + ' (expected ' + repr(correct) + ', got ' + \ + repr(got) + ') when modifying ' + field + if isinstance(correct, float): + self.assertAlmostEqual(got, correct, msg=message) + else: + self.assertEqual(got, correct, message) + + def _run(self, tagset, kind): + correct_dict = CORRECT_DICTS[tagset] + path = os.path.join(_common.RSRC, tagset + '.' + kind) + + for field in correct_dict: + if field == 'month' and correct_dict['year'] == 0 or \ + field == 'day' and correct_dict['month'] == 0: + continue + + # Generate the new value we'll try storing. + if field == 'art': + value = 'xxx' + elif type(correct_dict[field]) is unicode: + value = u'TestValue: ' + field + elif type(correct_dict[field]) is int: + value = correct_dict[field] + 42 + elif type(correct_dict[field]) is bool: + value = not correct_dict[field] + elif type(correct_dict[field]) is datetime.date: + value = correct_dict[field] + datetime.timedelta(42) + elif type(correct_dict[field]) is str: + value = 'TestValue-' + str(field) + elif type(correct_dict[field]) is float: + value = 9.87 + else: + raise ValueError('unknown field type ' + \ + str(type(correct_dict[field]))) + + # Make a copy of the file we'll work on. + root, ext = os.path.splitext(path) + tpath = root + '_test' + ext + shutil.copy(path, tpath) + + try: + self._write_field(tpath, field, value, correct_dict) + finally: + os.remove(tpath) + +class ReadOnlyTest(unittest.TestCase): + def _read_field(self, mf, field, value): + got = getattr(mf, field) + fail_msg = field + ' incorrect (expected ' + \ + repr(value) + ', got ' + repr(got) + ')' + if field == 'length': + self.assertTrue(value-0.1 < got < value+0.1, fail_msg) + else: + self.assertEqual(got, value, fail_msg) + + def _run(self, filename): + path = os.path.join(_common.RSRC, filename) + f = beets.mediafile.MediaFile(path) + correct_dict = READ_ONLY_CORRECT_DICTS[filename] + for field, value in correct_dict.items(): + self._read_field(f, field, value) + + def test_mp3(self): + self._run('full.mp3') + + def test_m4a(self): + self._run('full.m4a') + + def test_flac(self): + self._run('full.flac') + + def test_ogg(self): + self._run('full.ogg') + + def test_ape(self): + self._run('full.ape') + + def test_wv(self): + self._run('full.wv') + + def test_mpc(self): + self._run('full.mpc') + +def suite(): + return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == '__main__': unittest.main(defaultTest='suite') From c499be05ea3454f6b4f5faab46785f68349883eb Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 27 Nov 2012 21:47:15 -0800 Subject: [PATCH 20/40] fix some tests under PyPy In PyPy's pure-Python implementation of the sqlite3 module, sqlite3.Row has no __len__ method. This works around calling len(row). --- test/test_db.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/test_db.py b/test/test_db.py index dc1d0ce0f..4d451c3f1 100644 --- a/test/test_db.py +++ b/test/test_db.py @@ -654,7 +654,7 @@ class MigrationTest(unittest.TestCase): c = new_lib._connection().cursor() c.execute("select * from items") row = c.fetchone() - self.assertEqual(len(row), len(self.old_fields)) + self.assertEqual(len(row.keys()), len(self.old_fields)) def test_open_with_new_field_adds_column(self): new_lib = beets.library.Library(self.libfile, @@ -662,7 +662,7 @@ class MigrationTest(unittest.TestCase): c = new_lib._connection().cursor() c.execute("select * from items") row = c.fetchone() - self.assertEqual(len(row), len(self.new_fields)) + self.assertEqual(len(row.keys()), len(self.new_fields)) def test_open_with_fewer_fields_leaves_untouched(self): new_lib = beets.library.Library(self.libfile, @@ -670,7 +670,7 @@ class MigrationTest(unittest.TestCase): c = new_lib._connection().cursor() c.execute("select * from items") row = c.fetchone() - self.assertEqual(len(row), len(self.old_fields)) + self.assertEqual(len(row.keys()), len(self.old_fields)) def test_open_with_multiple_new_fields(self): new_lib = beets.library.Library(self.libfile, @@ -678,7 +678,7 @@ class MigrationTest(unittest.TestCase): c = new_lib._connection().cursor() c.execute("select * from items") row = c.fetchone() - self.assertEqual(len(row), len(self.newer_fields)) + self.assertEqual(len(row.keys()), len(self.newer_fields)) def test_open_old_db_adds_album_table(self): conn = sqlite3.connect(self.libfile) From 335ce9c73c06727f1a9ae28c4ed21584e982c3a2 Mon Sep 17 00:00:00 2001 From: Wieland Hoffmann Date: Sat, 1 Dec 2012 10:53:35 +0100 Subject: [PATCH 21/40] Some grammar corrections for docs/.../ihate.rst --- docs/plugins/ihate.rst | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/plugins/ihate.rst b/docs/plugins/ihate.rst index 24e46fd14..0d8e9e3b4 100644 --- a/docs/plugins/ihate.rst +++ b/docs/plugins/ihate.rst @@ -3,25 +3,25 @@ IHate Plugin The ``ihate`` plugin allows you to automatically skip things you hate during import or warn you about them. It supports album, artist and genre patterns. -Also there is whitelist to avoid skipping bands you still like. There are two -groups: warn and skip. Skip group is checked first. Whitelist overrides any +There also is a whitelist to avoid skipping bands you still like. There are two +groups: warn and skip. The skip group is checked first. Whitelist overrides any other patterns. -To use plugin, enable it by including ``ihate`` into ``plugins`` line of +To use the plugin, enable it by including ``ihate`` in the ``plugins`` line of your beets config:: [beets] plugins = ihate -You need to configure plugin before use, so add following section into config -file and adjust it to your needs:: +You need to configure the plugin before use, so add the following section into +your config file and adjust it to your needs:: [ihate] # you will be warned about these suspicious genres/artists (regexps): warn_genre=rnb soul power\smetal warn_artist=bad\band another\sbad\sband warn_album=tribute\sto - # if you don't like genre in general, but accept some band playing it, + # if you don't like a genre in general, but accept some band playing it, # add exceptions here: warn_whitelist=hate\sexception # never import any of this: @@ -31,5 +31,5 @@ file and adjust it to your needs:: # but import this: skip_whitelist= -Note: plugin will trust you decision in 'as-is' mode. +Note: The plugin will trust your decision in 'as-is' mode. \ No newline at end of file From 0fe2331842912acb74950f2691fa882a71f714ab Mon Sep 17 00:00:00 2001 From: David Brenner Date: Sun, 2 Dec 2012 18:27:20 -0500 Subject: [PATCH 22/40] Create echonest_tempo plugin - use EchoNest API to get tempo (bpm). A simple plugin that connects to the EchoNest API to retrieve tempo (bpm) metadata for tracks. Functions similarly to the lyrics plugin. Requires the pyechonest library. --- beetsplug/echonest_tempo.py | 108 ++++++++++++++++++++++++++++++++ docs/plugins/echonest_tempo.rst | 69 ++++++++++++++++++++ docs/plugins/index.rst | 2 + 3 files changed, 179 insertions(+) create mode 100644 beetsplug/echonest_tempo.py create mode 100644 docs/plugins/echonest_tempo.rst diff --git a/beetsplug/echonest_tempo.py b/beetsplug/echonest_tempo.py new file mode 100644 index 000000000..500981ad8 --- /dev/null +++ b/beetsplug/echonest_tempo.py @@ -0,0 +1,108 @@ +# This file is part of beets. +# Copyright 2012, David Brenner +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +"""Gets tempo (bpm) for imported music from the EchoNest API. Requires +the pyechonest library (https://github.com/echonest/pyechonest). +""" +import logging +from beets.plugins import BeetsPlugin +from beets import ui +from beets.ui import commands +import pyechonest.config +import pyechonest.song + +# Global logger. + +log = logging.getLogger('beets') + +# The user's EchoNest API key, if provided +_echonest_apikey = None + +def fetch_item_tempo(lib, loglevel, item, write): + """Fetch and store tempo for a single item. If ``write``, then the + tempo will also be written to the file itself in the bpm field. The + ``loglevel`` parameter controls the visibility of the function's + status log messages. + """ + # Skip if the item already has the tempo field. + if item.bpm: + log.log(loglevel, u'bpm already present: %s - %s' % + (item.artist, item.title)) + return + + # Fetch tempo. + tempo = get_tempo(item.artist, item.title) + if not tempo: + log.log(loglevel, u'tempo not found: %s - %s' % + (item.artist, item.title)) + return + + log.log(loglevel, u'fetched tempo: %s - %s' % + (item.artist, item.title)) + item.bpm = tempo + if write: + item.write() + lib.store(item) + +def get_tempo(artist, title): + "gets the tempo for a song" + + # Unfortunately, all we can do is search by artist and title. EchoNest + # supports foreign ids from MusicBrainz, but currently only for artists, + # not individual tracks/recordings. + results = pyechonest.song.search(artist=artist, title=title, results=1, buckets=['audio_summary']) + if len(results) > 0: + return results[0].audio_summary['tempo'] + else: + return None + +AUTOFETCH = True +class EchoNestTempoPlugin(BeetsPlugin): + def __init__(self): + super(EchoNestTempoPlugin, self).__init__() + self.import_stages = [self.imported] + + def commands(self): + cmd = ui.Subcommand('tempo', help='fetch song tempo (bpm)') + cmd.parser.add_option('-p', '--print', dest='printlyr', + action='store_true', default=False, + help='print tempo (bpm) to console') + def func(lib, config, opts, args): + # The "write to files" option corresponds to the + # import_write config value. + if not _echonest_apikey: + raise ui.UserError('no EchoNest user API key provided') + + write = ui.config_val(config, 'beets', 'import_write', + commands.DEFAULT_IMPORT_WRITE, bool) + + for item in lib.items(ui.decargs(args)): + fetch_item_tempo(lib, logging.INFO, item, write) + if opts.printlyr and item.bpm: + ui.print_(item.bpm) + cmd.func = func + return [cmd] + + def configure(self, config): + global AUTOFETCH, _echonest_apikey + AUTOFETCH = ui.config_val(config, 'echonest_tempo', 'autofetch', True, bool) + _echonest_apikey = ui.config_val(config, 'echonest_tempo', 'apikey', + None) + pyechonest.config.ECHO_NEST_API_KEY = _echonest_apikey + + # Auto-fetch tempo on import. + def imported(self, config, task): + if AUTOFETCH: + for item in task.imported_items(): + fetch_item_tempo(config.lib, logging.DEBUG, item, False) diff --git a/docs/plugins/echonest_tempo.rst b/docs/plugins/echonest_tempo.rst new file mode 100644 index 000000000..5bb0fb14b --- /dev/null +++ b/docs/plugins/echonest_tempo.rst @@ -0,0 +1,69 @@ +EchoNest Tempo Plugin +============= + +The ``echonest_tempo`` plugin fetches and stores a track's tempo (bpm field) + from the `EchoNest API`_ + +.. _EchoNest API: http://developer.echonest.com/ + +Installing Dependencies +----------------------- + +This plugin requires the pyechonest library in order to talk to the EchoNest +API. + +There are packages for most major linux distributions, you can download the +library from the EchoNest, or you can install the library from `pip`_, +like so:: + + $ pip install pyacoustid + +.. _pip: http://pip.openplans.org/ + +Configuring +----------- + +The plugin requires an EchoNest API key in order to function. To do this, +first `apply for an API key`_ from the EchoNest. Then, add the key to +your :doc:`/reference/config` as the value ``apikey`` in a section called +``echonest_tempo`` like so:: + + [echonest_tempo] + apikey=YOUR_API_KEY + +In addition, this plugin has one configuration option, ``autofetch``, which +lets you disable automatic tempo fetching during import. To do so, add this +to your ``~/.beetsconfig``:: + + [echonest_tempo] + apikey=YOUR_API_KEY + autofetch: no + +.. _apply for an API key: http://developer.echonest.com/account/register + +Fetch Tempo During Import +-------------------------- + +To automatically fetch the tempo for songs you import, just enable the plugin +by putting ``echonest_tempo`` on your config file's ``plugins`` line (see +:doc:`/plugins/index`), along with adding your EchoNest API key to your +``~/.beetsconfig``. When importing new files, beets will now fetch the +tempo for files that don't already have them. The bpm field will be stored in +the beets database. If the ``import_write`` config option is on, then the +tempo will also be written to the files' tags. + +This behavior can be disabled with the ``autofetch`` config option (see below). + +Fetching Tempo Manually +------------------------ + +The ``echonest_tempo`` command provided by this plugin fetches tempos for +items that match a query (see :doc:`/reference/query`). For example, +``beet tempo magnetic fields absolutely cuckoo`` will get the tempo for the +appropriate Magnetic Fields song, ``beet tempo magnetic fields`` will get +tempos for all my tracks by that band, and ``beet tempo`` will get tempos for +my entire library. The tempos will be added to the beets database and, if +``import_write`` is on, embedded into files' metadata. + +The ``-p`` option to the ``tempo`` command makes it print tempos out to the +console so you can view the fetched (or previously-stored) tempos. diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index b608245ba..1132de456 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -37,6 +37,7 @@ disabled by default, but you can turn them on as described above. chroma lyrics + echonest_tempo bpd mpdupdate fetchart @@ -67,6 +68,7 @@ Metadata '''''''' * :doc:`lyrics`: Automatically fetch song lyrics. +* :doc:`echonest_tempo`: Automatically fetch song tempos (bpm). * :doc:`lastgenre`: Fetch genres based on Last.fm tags. * :doc:`fetchart`: Fetch album cover art from various sources. * :doc:`embedart`: Embed album art images into files' metadata. From a8cf42d05b4c6ef6c6d8c0f7223b04519be100a5 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 2 Dec 2012 16:25:45 -0800 Subject: [PATCH 23/40] echonest_tempo: changelog + doc fixes + print fix --- beets/library.py | 1 + beetsplug/echonest_tempo.py | 6 +++--- docs/changelog.rst | 3 +++ docs/plugins/echonest_tempo.rst | 14 +++++++------- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/beets/library.py b/beets/library.py index f8a70fe2a..cbc1d6018 100644 --- a/beets/library.py +++ b/beets/library.py @@ -157,6 +157,7 @@ PF_KEY_DEFAULT = 'default' log = logging.getLogger('beets') if not log.handlers: log.addHandler(logging.StreamHandler()) +log.propagate = False # Don't propagate to root handler. # A little SQL utility. def _orelse(exp1, exp2): diff --git a/beetsplug/echonest_tempo.py b/beetsplug/echonest_tempo.py index 500981ad8..6132287ca 100644 --- a/beetsplug/echonest_tempo.py +++ b/beetsplug/echonest_tempo.py @@ -75,7 +75,7 @@ class EchoNestTempoPlugin(BeetsPlugin): def commands(self): cmd = ui.Subcommand('tempo', help='fetch song tempo (bpm)') - cmd.parser.add_option('-p', '--print', dest='printlyr', + cmd.parser.add_option('-p', '--print', dest='printbpm', action='store_true', default=False, help='print tempo (bpm) to console') def func(lib, config, opts, args): @@ -89,8 +89,8 @@ class EchoNestTempoPlugin(BeetsPlugin): for item in lib.items(ui.decargs(args)): fetch_item_tempo(lib, logging.INFO, item, write) - if opts.printlyr and item.bpm: - ui.print_(item.bpm) + if opts.printbpm and item.bpm: + ui.print_('{0} BPM'.format(item.bpm)) cmd.func = func return [cmd] diff --git a/docs/changelog.rst b/docs/changelog.rst index 797f216af..a655cd39b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -10,6 +10,8 @@ Changelog * New plugin: :doc:`/plugins/fuzzy_search` lets you find albums and tracks using fuzzy string matching so you don't have to type (or even remember) their exact names. Thanks to Philippe Mongeau. +* New plugin: :doc:`/plugins/echonest_tempo` fetches tempo (BPM) information + from `The Echo Nest`_. Thanks to David Brenner. * New plugin: :doc:`/plugins/the` adds a template function that helps format text for nicely-sorted directory listings. Thanks to Blemjhoo Tezoulbr. * New plugin: :doc:`/plugins/zero` filters out undesirable fields before they @@ -80,6 +82,7 @@ Changelog * Changed plugin loading so that modules can be imported without unintentionally loading the plugins they contain. +.. _The Echo Nest: http://the.echonest.com/ .. _Tomahawk resolver: http://beets.radbox.org/blog/tomahawk-resolver.html .. _mp3gain: http://mp3gain.sourceforge.net/download.php .. _aacgain: http://aacgain.altosdesign.com diff --git a/docs/plugins/echonest_tempo.rst b/docs/plugins/echonest_tempo.rst index 5bb0fb14b..009ad9c5c 100644 --- a/docs/plugins/echonest_tempo.rst +++ b/docs/plugins/echonest_tempo.rst @@ -1,8 +1,8 @@ EchoNest Tempo Plugin -============= +===================== -The ``echonest_tempo`` plugin fetches and stores a track's tempo (bpm field) - from the `EchoNest API`_ +The ``echonest_tempo`` plugin fetches and stores a track's tempo (the "bpm" +field) from the `EchoNest API`_ .. _EchoNest API: http://developer.echonest.com/ @@ -16,7 +16,7 @@ There are packages for most major linux distributions, you can download the library from the EchoNest, or you can install the library from `pip`_, like so:: - $ pip install pyacoustid + $ pip install pyechonest .. _pip: http://pip.openplans.org/ @@ -42,7 +42,7 @@ to your ``~/.beetsconfig``:: .. _apply for an API key: http://developer.echonest.com/account/register Fetch Tempo During Import --------------------------- +------------------------- To automatically fetch the tempo for songs you import, just enable the plugin by putting ``echonest_tempo`` on your config file's ``plugins`` line (see @@ -55,9 +55,9 @@ tempo will also be written to the files' tags. This behavior can be disabled with the ``autofetch`` config option (see below). Fetching Tempo Manually ------------------------- +----------------------- -The ``echonest_tempo`` command provided by this plugin fetches tempos for +The ``tempo`` command provided by this plugin fetches tempos for items that match a query (see :doc:`/reference/query`). For example, ``beet tempo magnetic fields absolutely cuckoo`` will get the tempo for the appropriate Magnetic Fields song, ``beet tempo magnetic fields`` will get From f5628e03f6e303d3e1363dc6b9e3e24a5b148eef Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 2 Dec 2012 16:32:19 -0800 Subject: [PATCH 24/40] include official beets Echo Nest API key --- beetsplug/echonest_tempo.py | 27 +++++++++++++-------------- docs/plugins/echonest_tempo.rst | 11 +++++------ 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/beetsplug/echonest_tempo.py b/beetsplug/echonest_tempo.py index 6132287ca..df9ff16b4 100644 --- a/beetsplug/echonest_tempo.py +++ b/beetsplug/echonest_tempo.py @@ -23,11 +23,11 @@ import pyechonest.config import pyechonest.song # Global logger. - log = logging.getLogger('beets') -# The user's EchoNest API key, if provided -_echonest_apikey = None +# The official Echo Nest API key for beets. This can be overridden by +# the user. +ECHONEST_APIKEY = 'NY2KTZHQ0QDSHBAP6' def fetch_item_tempo(lib, loglevel, item, write): """Fetch and store tempo for a single item. If ``write``, then the @@ -56,12 +56,13 @@ def fetch_item_tempo(lib, loglevel, item, write): lib.store(item) def get_tempo(artist, title): - "gets the tempo for a song" - + """Get the tempo for a song.""" # Unfortunately, all we can do is search by artist and title. EchoNest # supports foreign ids from MusicBrainz, but currently only for artists, # not individual tracks/recordings. - results = pyechonest.song.search(artist=artist, title=title, results=1, buckets=['audio_summary']) + results = pyechonest.song.search( + artist=artist, title=title, results=1, buckets=['audio_summary'] + ) if len(results) > 0: return results[0].audio_summary['tempo'] else: @@ -81,9 +82,6 @@ class EchoNestTempoPlugin(BeetsPlugin): def func(lib, config, opts, args): # The "write to files" option corresponds to the # import_write config value. - if not _echonest_apikey: - raise ui.UserError('no EchoNest user API key provided') - write = ui.config_val(config, 'beets', 'import_write', commands.DEFAULT_IMPORT_WRITE, bool) @@ -95,11 +93,12 @@ class EchoNestTempoPlugin(BeetsPlugin): return [cmd] def configure(self, config): - global AUTOFETCH, _echonest_apikey - AUTOFETCH = ui.config_val(config, 'echonest_tempo', 'autofetch', True, bool) - _echonest_apikey = ui.config_val(config, 'echonest_tempo', 'apikey', - None) - pyechonest.config.ECHO_NEST_API_KEY = _echonest_apikey + global AUTOFETCH + AUTOFETCH = ui.config_val(config, 'echonest_tempo', 'autofetch', True, + bool) + apikey = ui.config_val(config, 'echonest_tempo', 'apikey', + ECHONEST_APIKEY) + pyechonest.config.ECHO_NEST_API_KEY = apikey # Auto-fetch tempo on import. def imported(self, config, task): diff --git a/docs/plugins/echonest_tempo.rst b/docs/plugins/echonest_tempo.rst index 009ad9c5c..3f7274bfc 100644 --- a/docs/plugins/echonest_tempo.rst +++ b/docs/plugins/echonest_tempo.rst @@ -13,7 +13,7 @@ This plugin requires the pyechonest library in order to talk to the EchoNest API. There are packages for most major linux distributions, you can download the -library from the EchoNest, or you can install the library from `pip`_, +library from the Echo Nest, or you can install the library from `pip`_, like so:: $ pip install pyechonest @@ -23,9 +23,9 @@ like so:: Configuring ----------- -The plugin requires an EchoNest API key in order to function. To do this, -first `apply for an API key`_ from the EchoNest. Then, add the key to -your :doc:`/reference/config` as the value ``apikey`` in a section called +Beets includes its own Echo Nest API key, but you can `apply for your own`_ for +free from the EchoNest. To specify your own API key, add the key to your +:doc:`/reference/config` as the value ``apikey`` in a section called ``echonest_tempo`` like so:: [echonest_tempo] @@ -36,10 +36,9 @@ lets you disable automatic tempo fetching during import. To do so, add this to your ``~/.beetsconfig``:: [echonest_tempo] - apikey=YOUR_API_KEY autofetch: no -.. _apply for an API key: http://developer.echonest.com/account/register +.. _apply for your own: http://developer.echonest.com/account/register Fetch Tempo During Import ------------------------- From 4f5c5d4e4f178b7b87cdbc1b376e8fca67a4bf96 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 2 Dec 2012 16:34:05 -0800 Subject: [PATCH 25/40] echonest_tempo: docs wording fix --- docs/plugins/echonest_tempo.rst | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/plugins/echonest_tempo.rst b/docs/plugins/echonest_tempo.rst index 3f7274bfc..0b1df07ee 100644 --- a/docs/plugins/echonest_tempo.rst +++ b/docs/plugins/echonest_tempo.rst @@ -31,9 +31,8 @@ free from the EchoNest. To specify your own API key, add the key to your [echonest_tempo] apikey=YOUR_API_KEY -In addition, this plugin has one configuration option, ``autofetch``, which -lets you disable automatic tempo fetching during import. To do so, add this -to your ``~/.beetsconfig``:: +In addition, the ``autofetch`` config option lets you disable automatic tempo +fetching during import. To do so, add this to your ``~/.beetsconfig``:: [echonest_tempo] autofetch: no From 22d17aaa30a2582914a03ded6df1122f6efefe0a Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 2 Dec 2012 16:58:49 -0800 Subject: [PATCH 26/40] handle non-Unicode exception strings correctly --- beets/util/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index d798cb102..7bb64bd24 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -59,12 +59,14 @@ class HumanReadableException(Exception): def _reasonstr(self): """Get the reason as a string.""" - if isinstance(self.reason, basestring): + if isinstance(self.reason, unicode): return self.reason + elif isinstance(self.reason, basestring): # Byte string. + return self.reason.decode('utf8', 'ignore') elif hasattr(self.reason, 'strerror'): # i.e., EnvironmentError return self.reason.strerror else: - return u'"{0}"'.format(self.reason) + return u'"{0}"'.format(unicode(self.reason)) def get_message(self): """Create the human-readable description of the error, sans From 131b19188566108238225486380a565b25232773 Mon Sep 17 00:00:00 2001 From: jizz Date: Tue, 4 Dec 2012 16:24:15 +1100 Subject: [PATCH 27/40] Update beets/ui/__init__.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix for Issue 450: Expand templates in art_filename config option --- beets/ui/__init__.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index a1dc8bd04..a338f456f 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -516,6 +516,20 @@ def _get_path_formats(config): return path_formats +def _get_art_filename(config): + """Returns a string of album art format; reflecting + the config's specified album art filename. + """ + legacy_art_filename = config_val(config, 'beets', 'art_filename', None) + if legacy_art_filename: + # Old path formats override the default values. + art_filename = Template(legacy_art_filename) + else: + # If no legacy path format, use the defaults instead. + art_filename = DEFAULT_ART_FILENAME + + return art_filename + def _pick_format(config=None, album=False, fmt=None): """Pick a format string for printing Album or Item objects, falling back to config options and defaults. @@ -771,8 +785,7 @@ def _raw_main(args, configfh): directory = options.directory or \ config_val(config, 'beets', 'directory', default_dir) path_formats = _get_path_formats(config) - art_filename = \ - config_val(config, 'beets', 'art_filename', DEFAULT_ART_FILENAME) + art_filename = _get_art_filename(config) lib_timeout = config_val(config, 'beets', 'timeout', DEFAULT_TIMEOUT) replacements = _get_replacements(config) try: From 6b7b54062f7b6f2425c960d9e7e4f175d945c07d Mon Sep 17 00:00:00 2001 From: jizz Date: Tue, 4 Dec 2012 16:25:18 +1100 Subject: [PATCH 28/40] Update beets/library.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix for Issue 450: Expand templates in art_filename config option --- beets/library.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/beets/library.py b/beets/library.py index cbc1d6018..b57c51943 100644 --- a/beets/library.py +++ b/beets/library.py @@ -1002,7 +1002,7 @@ class Library(BaseLibrary): self.path = bytestring_path(normpath(path)) self.directory = bytestring_path(normpath(directory)) self.path_formats = path_formats - self.art_filename = bytestring_path(art_filename) + self.art_filename = art_filename self.replacements = replacements self._memotable = {} # Used for template substitution performance. @@ -1564,8 +1564,11 @@ class Album(BaseAlbum): """ image = bytestring_path(image) item_dir = item_dir or self.item_dir() + sanitized_art_filename = util.sanitize_for_path(self.evaluate_template(self._library.art_filename),os.path) _, ext = os.path.splitext(image) - dest = os.path.join(item_dir, self._library.art_filename + ext) + + dest = os.path.join(item_dir, util.sanitize_path(sanitized_art_filename) + ext) + return dest def set_art(self, path, copy=True): From c56c52c5d50ef7c1cdfebd985f1d131cdb2ac471 Mon Sep 17 00:00:00 2001 From: jizz Date: Wed, 5 Dec 2012 11:02:34 +1100 Subject: [PATCH 29/40] updated comments --- beets/ui/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index a338f456f..1dbe7056e 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -522,10 +522,10 @@ def _get_art_filename(config): """ legacy_art_filename = config_val(config, 'beets', 'art_filename', None) if legacy_art_filename: - # Old path formats override the default values. + # Old art filename format override the default value. art_filename = Template(legacy_art_filename) else: - # If no legacy path format, use the defaults instead. + # If no legacy art filename format, use the default instead. art_filename = DEFAULT_ART_FILENAME return art_filename From 12aaa2ee34138a532eae94f6c4bc5e9c7980e32c Mon Sep 17 00:00:00 2001 From: jizz Date: Wed, 5 Dec 2012 11:05:12 +1100 Subject: [PATCH 30/40] updated art_destination art_filename now acceptable as a string or a Template --- beets/library.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/beets/library.py b/beets/library.py index b57c51943..5d9921d63 100644 --- a/beets/library.py +++ b/beets/library.py @@ -1562,14 +1562,21 @@ class Album(BaseAlbum): items, so the album must contain at least one item or item_dir must be provided. """ + + if isinstance(self._library.art_filename,Template): + art_filename_template = self._library.art_filename,Template + else: + art_filename_template = Template(self._library.art_filename) + + image = bytestring_path(image) item_dir = item_dir or self.item_dir() - sanitized_art_filename = util.sanitize_for_path(self.evaluate_template(self._library.art_filename),os.path) + sanitized_art_filename = util.sanitize_for_path(self.evaluate_template(art_filename_template),os.path) _, ext = os.path.splitext(image) dest = os.path.join(item_dir, util.sanitize_path(sanitized_art_filename) + ext) - return dest + return bytestring_path(dest) def set_art(self, path, copy=True): """Sets the album's cover art to the image at the given path. From fe1b40f7d27adb0a210002b4f6390a7edfb19fb8 Mon Sep 17 00:00:00 2001 From: jizz Date: Wed, 5 Dec 2012 11:58:10 +1100 Subject: [PATCH 31/40] minor tweak --- beets/library.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/beets/library.py b/beets/library.py index 5d9921d63..2bf291c44 100644 --- a/beets/library.py +++ b/beets/library.py @@ -1563,15 +1563,13 @@ class Album(BaseAlbum): item_dir must be provided. """ - if isinstance(self._library.art_filename,Template): - art_filename_template = self._library.art_filename,Template - else: - art_filename_template = Template(self._library.art_filename) - - image = bytestring_path(image) item_dir = item_dir or self.item_dir() - sanitized_art_filename = util.sanitize_for_path(self.evaluate_template(art_filename_template),os.path) + + if not isinstance(self._library.art_filename,Template): + self._library.art_filename = Template(self._library.art_filename) + + sanitized_art_filename = util.sanitize_for_path(self.evaluate_template(self._library.art_filename),os.path) _, ext = os.path.splitext(image) dest = os.path.join(item_dir, util.sanitize_path(sanitized_art_filename) + ext) From 128a881bb4cada8f535c5041cadeedb10293d7be Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Wed, 5 Dec 2012 09:10:07 -0800 Subject: [PATCH 32/40] GH-69/GC-450: docs, changelog, simplification --- beets/library.py | 10 ++++++---- beets/ui/__init__.py | 17 ++--------------- beets/util/__init__.py | 5 ++++- docs/changelog.rst | 2 ++ docs/reference/config.rst | 7 ++++--- 5 files changed, 18 insertions(+), 23 deletions(-) diff --git a/beets/library.py b/beets/library.py index 2bf291c44..8d8958201 100644 --- a/beets/library.py +++ b/beets/library.py @@ -1562,17 +1562,19 @@ class Album(BaseAlbum): items, so the album must contain at least one item or item_dir must be provided. """ - image = bytestring_path(image) item_dir = item_dir or self.item_dir() if not isinstance(self._library.art_filename,Template): self._library.art_filename = Template(self._library.art_filename) - sanitized_art_filename = util.sanitize_for_path(self.evaluate_template(self._library.art_filename),os.path) - _, ext = os.path.splitext(image) + subpath = util.sanitize_path(util.sanitize_for_path( + self.evaluate_template(self._library.art_filename) + )) + subpath = bytestring_path(subpath) - dest = os.path.join(item_dir, util.sanitize_path(sanitized_art_filename) + ext) + _, ext = os.path.splitext(image) + dest = os.path.join(item_dir, subpath + ext) return bytestring_path(dest) diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 1dbe7056e..0140d8bd5 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -516,20 +516,6 @@ def _get_path_formats(config): return path_formats -def _get_art_filename(config): - """Returns a string of album art format; reflecting - the config's specified album art filename. - """ - legacy_art_filename = config_val(config, 'beets', 'art_filename', None) - if legacy_art_filename: - # Old art filename format override the default value. - art_filename = Template(legacy_art_filename) - else: - # If no legacy art filename format, use the default instead. - art_filename = DEFAULT_ART_FILENAME - - return art_filename - def _pick_format(config=None, album=False, fmt=None): """Pick a format string for printing Album or Item objects, falling back to config options and defaults. @@ -785,7 +771,8 @@ def _raw_main(args, configfh): directory = options.directory or \ config_val(config, 'beets', 'directory', default_dir) path_formats = _get_path_formats(config) - art_filename = _get_art_filename(config) + art_filename = Template(config_val(config, 'beets', 'art_filename', + DEFAULT_ART_FILENAME)) lib_timeout = config_val(config, 'beets', 'timeout', DEFAULT_TIMEOUT) replacements = _get_replacements(config) try: diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 7bb64bd24..12a338439 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -476,11 +476,13 @@ def truncate_path(path, pathmod=None, length=MAX_FILENAME_LENGTH): return pathmod.join(*out) -def sanitize_for_path(value, pathmod, key=None): +def sanitize_for_path(value, pathmod=None, key=None): """Sanitize the value for inclusion in a path: replace separators with _, etc. Doesn't guarantee that the whole path will be valid; you should still call sanitize_path on the complete path. """ + pathmod = pathmod or os.path + if isinstance(value, basestring): for sep in (pathmod.sep, pathmod.altsep): if sep: @@ -500,6 +502,7 @@ def sanitize_for_path(value, pathmod, key=None): value = u'%ikHz' % ((value or 0) // 1000) else: value = unicode(value) + return value def str2bool(value): diff --git a/docs/changelog.rst b/docs/changelog.rst index a655cd39b..8b9544769 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -35,6 +35,8 @@ Changelog * The output of the :ref:`update-cmd`, :ref:`remove-cmd`, and :ref:`modify-cmd` commands now respects the :ref:`list_format_album` and :ref:`list_format_item` config options. Thanks to Mike Kazantsev. +* The :ref:`art-filename` option can now be a template rather than a simple + string. Thanks to Jarrod Beardwood. * Fix album queries for ``artpath`` and other non-item fields. * Null values in the database can now be matched with the empty-string regular expression, ``^$``. diff --git a/docs/reference/config.rst b/docs/reference/config.rst index eb1954a2d..102d7ef2b 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -157,9 +157,10 @@ 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 album's -directory). +cover art image should be placed. This is a template string, so you can use any +of the syntax available to :doc:`/reference/pathformat`. Defaults to ``cover`` +(i.e., images will be named ``cover.jpg`` or ``cover.png`` and placed in the +album's directory). plugins ~~~~~~~ From 3e9135ee7cf0865e1ed006241275a90ff9c9728a Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Fri, 7 Dec 2012 14:49:11 -0800 Subject: [PATCH 33/40] syspath-ify call to pyacoustid (GC-464) --- beetsplug/chroma.py | 2 +- docs/changelog.rst | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py index 9b3229dd1..e2eb4af3a 100644 --- a/beetsplug/chroma.py +++ b/beetsplug/chroma.py @@ -52,7 +52,7 @@ def acoustid_match(path): _matches, _fingerprints, and _acoustids dictionaries accordingly. """ try: - duration, fp = acoustid.fingerprint_file(path) + duration, fp = acoustid.fingerprint_file(util.syspath(path)) except acoustid.FingerprintGenerationError as exc: log.error('fingerprinting of %s failed: %s' % (repr(path), str(exc))) diff --git a/docs/changelog.rst b/docs/changelog.rst index 8b9544769..2a3f3a660 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -79,6 +79,7 @@ Changelog * Fix some problem with identifying files on Windows with Unicode directory names in their path. * Fix a crash when Unicode queries were used with ``import -L`` re-imports. +* Fix an error when fingerprinting files with Unicode filenames on Windows. * Add human-readable error messages when writing files' tags fails or when a directory can't be created. * Changed plugin loading so that modules can be imported without From ae7b29b69812468a47c2eefe6baf26f2bb35badd Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 8 Dec 2012 12:03:42 -0800 Subject: [PATCH 34/40] log Mutagen exceptions in debug message --- beets/mediafile.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beets/mediafile.py b/beets/mediafile.py index b5e0acce2..b99e31f66 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -691,8 +691,8 @@ class MediaFile(object): ) try: self.mgfile = mutagen.File(path) - except unreadable_exc: - log.warn('header parsing failed') + except unreadable_exc as exc: + log.debug(u'header parsing failed: {0}'.format(unicode(exc))) raise UnreadableFileError('Mutagen could not read file') except IOError: raise UnreadableFileError('could not read file') From 98892945c255b3c977a64acce81012f2f91c13cb Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 8 Dec 2012 12:10:06 -0800 Subject: [PATCH 35/40] don't crash when singleton-importing broken files --- beets/importer.py | 9 ++++++++- docs/changelog.rst | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/beets/importer.py b/beets/importer.py index f58481bd8..548df9572 100644 --- a/beets/importer.py +++ b/beets/importer.py @@ -29,6 +29,7 @@ from beets import util from beets.util import pipeline from beets.util import syspath, normpath, displayable_path from beets.util.enumeration import enum +from beets.mediafile import UnreadableFileError action = enum( 'SKIP', 'ASIS', 'TRACKS', 'MANUAL', 'APPLY', 'MANUAL_ID', @@ -485,7 +486,13 @@ def read_tasks(config): for toppath in config.paths: # Check whether the path is to a file. if config.singletons and not os.path.isdir(syspath(toppath)): - item = library.Item.from_path(toppath) + try: + item = library.Item.from_path(toppath) + except UnreadableFileError: + log.warn(u'unreadable file: {0}'.format( + util.displayable_path(toppath) + )) + continue yield ImportTask.item_task(item) continue diff --git a/docs/changelog.rst b/docs/changelog.rst index 2a3f3a660..b5cbe913e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -80,6 +80,7 @@ Changelog names in their path. * Fix a crash when Unicode queries were used with ``import -L`` re-imports. * Fix an error when fingerprinting files with Unicode filenames on Windows. +* Warn instead of crashing when importing a specific file in singleton mode. * Add human-readable error messages when writing files' tags fails or when a directory can't be created. * Changed plugin loading so that modules can be imported without From 531025f799b1b854f868bf7c54f87cac6b843e05 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 11 Dec 2012 13:06:57 -0800 Subject: [PATCH 36/40] replaygain: restrict file formats (GC-469) --- beetsplug/replaygain.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index 06fd02301..882032130 100755 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -162,6 +162,10 @@ class ReplayGainPlugin(BeetsPlugin): def requires_gain(self, item, album=False): """Does the gain need to be computed?""" + if 'mp3gain' in self.command and item.format != 'MP3': + return False + elif 'aacgain' in self.command and item.format not in ('MP3', 'AAC'): + return False return self.overwrite or \ (not item.rg_track_gain or not item.rg_track_peak) or \ ((not item.rg_album_gain or not item.rg_album_peak) and \ From 2d4b91fa6d0ca348a0d05eec0295caac3c7bba0a Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 11 Dec 2012 13:09:10 -0800 Subject: [PATCH 37/40] replaygain: handle RG tool invocation errors (GC-469) --- beetsplug/replaygain.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index 882032130..c84226b4c 100755 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -203,7 +203,11 @@ class ReplayGainPlugin(BeetsPlugin): cmd = cmd + [syspath(i.path) for i in items] log.debug(u'replaygain: analyzing {0} files'.format(len(items))) - output = call(cmd) + try: + output = call(cmd) + except ReplayGainError as exc: + log.warn(u'replaygain: analysis failed ({0})'.format(exc)) + return log.debug(u'replaygain: analysis finished') results = parse_tool_output(output) From faa21390a5ddde2b0db0ca5381d28b93339fdf0e Mon Sep 17 00:00:00 2001 From: Daniele Sluijters Date: Tue, 11 Dec 2012 13:36:45 -0800 Subject: [PATCH 38/40] mediafile: Handle FLAC albumart correctly. FLAC's metadata_block_picture should just contain the image, not base64'ed like ogg/vorbis wants it. --- beets/mediafile.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) --- beets/mediafile.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/beets/mediafile.py b/beets/mediafile.py index b99e31f66..864382202 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -572,9 +572,23 @@ class ImageField(object): # No cover found. return None + elif obj.type == 'flac': + if 'metadata_block_picture' not in obj.mgfile: + return None + + for data in obj.mgfile['metadata_block_picture']: + try: + pic = mutagen.flac.Picture(data) + break + except TypeError: + pass + else: + return None + + return pic.data else: - # Here we're assuming everything but MP3 and MPEG-4 uses + # Here we're assuming everything but MP3, FLAC and MPEG-4 use # the Xiph/Vorbis Comments standard. This may not be valid. # http://wiki.xiph.org/VorbisComment#Cover_art @@ -624,6 +638,13 @@ class ImageField(object): cover = mutagen.mp4.MP4Cover(val, self._mp4kind(val)) obj.mgfile['covr'] = [cover] + elif obj.type == 'flac': + if val is None: + pic = mutagen.flac.Picture() + pic.data = val + pic.mime = self._mime(val) + obj.mgfile['metadata_block_picture'] = [pic.write()] + else: # Again, assuming Vorbis Comments standard. From 909fb85a8e935c4fecbb0a8d45db2b681281d54d Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 11 Dec 2012 13:47:26 -0800 Subject: [PATCH 39/40] use Mutagen's API for FLAC images (GC-468) --- beets/mediafile.py | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/beets/mediafile.py b/beets/mediafile.py index 864382202..4f5be5ff4 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -572,25 +572,18 @@ class ImageField(object): # No cover found. return None - elif obj.type == 'flac': - if 'metadata_block_picture' not in obj.mgfile: - return None - for data in obj.mgfile['metadata_block_picture']: - try: - pic = mutagen.flac.Picture(data) - break - except TypeError: - pass + elif obj.type == 'flac': + pictures = obj.mgfile.pictures + if pictures: + return pictures[0].data or None else: return None - return pic.data - else: - # Here we're assuming everything but MP3, FLAC and MPEG-4 use - # the Xiph/Vorbis Comments standard. This may not be valid. - # http://wiki.xiph.org/VorbisComment#Cover_art + # Here we're assuming everything but MP3, MPEG-4, and FLAC + # use the Xiph/Vorbis Comments standard. This may not be + # valid. http://wiki.xiph.org/VorbisComment#Cover_art if 'metadata_block_picture' not in obj.mgfile: # Try legacy COVERART tags. @@ -639,11 +632,13 @@ class ImageField(object): obj.mgfile['covr'] = [cover] elif obj.type == 'flac': - if val is None: + obj.mgfile.clear_pictures() + + if val is not None: pic = mutagen.flac.Picture() pic.data = val pic.mime = self._mime(val) - obj.mgfile['metadata_block_picture'] = [pic.write()] + obj.mgfile.add_picture(pic) else: # Again, assuming Vorbis Comments standard. From 49ae56e45587fdb907a61949ca02f6b88352460a Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 11 Dec 2012 13:53:02 -0800 Subject: [PATCH 40/40] embedart: changelog for GC-468 --- docs/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index b5cbe913e..57974231b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -61,6 +61,10 @@ Changelog * :doc:`/plugins/mbcollection`: Fix an error when submitting a large number of releases (we now submit only 200 releases at a time instead of 350). Thanks to Jonathan Towne. +* :doc:`/plugins/embedart`: Made the method for embedding art into FLAC files + `standard + `_-compliant. + Thanks to Daniele Sluijters. * Add the track mapping dictionary to the ``album_distance`` plugin function. * When an exception is raised while reading a file, the path of the file in question is now logged (thanks to Mike Kazantsev).