From 781949fa01da4ce11d3dfe964e9e17f772fdc53a Mon Sep 17 00:00:00 2001 From: Sam Doshi Date: Sat, 16 Feb 2013 17:11:15 +0000 Subject: [PATCH 01/88] Add support for artist aliases Use MusicBrainz artist aliases to import files with artist names in an appriorate locale --- beets/autotag/mb.py | 39 +++++++++++++++++++++++++++++++++++++-- beets/config_default.yaml | 1 + 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/beets/autotag/mb.py b/beets/autotag/mb.py index 896f0cbb5..85108ad08 100644 --- a/beets/autotag/mb.py +++ b/beets/autotag/mb.py @@ -48,6 +48,11 @@ RELEASE_INCLUDES = ['artists', 'media', 'recordings', 'release-groups', 'labels', 'artist-credits'] TRACK_INCLUDES = ['artists'] +# only versions >= 0.3 support artist aliases +if musicbrainzngs.musicbrainz._version >= '0.3': + RELEASE_INCLUDES.append('aliases') + TRACK_INCLUDES.append('aliases') + def configure(): """Set up the python-musicbrainz-ngs module according to settings from the beets configuration. This should be called at startup. @@ -74,12 +79,42 @@ def _flatten_artist_credit(credit): artist_sort_parts.append(el) else: + prefered_locales = config['import']['artist_aliases'].as_str_seq() + chosen_alias = None + if 'alias-list' in el['artist']: + alias_list = el['artist']['alias-list'] + # Get a list of the aliases that have set locales + set_locales = [a for a in alias_list if 'locale' in a] + # Search locales in order + for locale in prefered_locales: + # Does anything match + matches = [a for a in set_locales if a['locale'] == locale] + # Skip to next locale if no matches + if len(matches) == 0: + continue + # Find the aliases that have the primary flag set + primaries = [a for a in matches if 'primary' in a] + # Take the primary if we have it, otherwise take the first + # match with the correct locale + if len(primaries) > 0: + chosen_alias = primaries[0] + else: + chosen_alias = matches[0] + # If we get here we must have found an acceptable alias + # so stop looking + break + # An artist. - cur_artist_name = el['artist']['name'] + if chosen_alias is not None: + cur_artist_name = chosen_alias['alias'] + else: + cur_artist_name = el['artist']['name'] artist_parts.append(cur_artist_name) # Artist sort name. - if 'sort-name' in el['artist']: + if chosen_alias is not None: + artist_sort_parts.append(chosen_alias['sort-name']) + elif 'sort-name' in el['artist']: artist_sort_parts.append(el['artist']['sort-name']) else: artist_sort_parts.append(cur_artist_name) diff --git a/beets/config_default.yaml b/beets/config_default.yaml index a466a63ef..fb2d78c9f 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -16,6 +16,7 @@ import: quiet: no singletons: no default_action: apply + artist_aliases: [] clutter: ["Thumbs.DB", ".DS_Store"] ignore: [".*", "*~"] From 9f5926a026713b6f0e6600f4c94f96e3ad8f3661 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 26 Feb 2013 14:29:55 -0800 Subject: [PATCH 02/88] refactor alias discovery into function (#102) --- beets/autotag/mb.py | 64 ++++++++++++++++++++++++--------------------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/beets/autotag/mb.py b/beets/autotag/mb.py index fc2564e74..0a47e0bb5 100644 --- a/beets/autotag/mb.py +++ b/beets/autotag/mb.py @@ -48,7 +48,7 @@ RELEASE_INCLUDES = ['artists', 'media', 'recordings', 'release-groups', 'labels', 'artist-credits'] TRACK_INCLUDES = ['artists'] -# only versions >= 0.3 support artist aliases +# Only versions >= 0.3 of python-musicbrainz-ngs support artist aliases. if musicbrainzngs.musicbrainz._version >= '0.3': RELEASE_INCLUDES.append('aliases') TRACK_INCLUDES.append('aliases') @@ -63,6 +63,33 @@ def configure(): config['musicbrainz']['ratelimit'].get(int), ) +def _preferred_alias(aliases): + """Given an list of alias structures for an artist credit, select + and return the user's preferred alias alias or None if no matching + alias is found. + """ + if not aliases: + return + + # Only consider aliases that have locales set. + aliases = [a for a in aliases if 'locale' in a] + + # Search configured locales in order. + for locale in config['import']['artist_aliases'].as_str_seq(): + # Find matching aliases for this locale. + matches = [a for a in aliases if a['locale'] == locale] + if not matches: + return + + # Find the aliases that have the primary flag set. + primaries = [a for a in matches if 'primary' in a] + # Take the primary if we have it, otherwise take the first + # match with the correct locale. + if primaries: + return primaries[0] + else: + return matches[0] + def _flatten_artist_credit(credit): """Given a list representing an ``artist-credit`` block, flatten the data into a triple of joined artist name strings: canonical, sort, and @@ -79,41 +106,18 @@ def _flatten_artist_credit(credit): artist_sort_parts.append(el) else: - prefered_locales = config['import']['artist_aliases'].as_str_seq() - chosen_alias = None - if 'alias-list' in el['artist']: - alias_list = el['artist']['alias-list'] - # Get a list of the aliases that have set locales - set_locales = [a for a in alias_list if 'locale' in a] - # Search locales in order - for locale in prefered_locales: - # Does anything match - matches = [a for a in set_locales if a['locale'] == locale] - # Skip to next locale if no matches - if len(matches) == 0: - continue - # Find the aliases that have the primary flag set - primaries = [a for a in matches if 'primary' in a] - # Take the primary if we have it, otherwise take the first - # match with the correct locale - if len(primaries) > 0: - chosen_alias = primaries[0] - else: - chosen_alias = matches[0] - # If we get here we must have found an acceptable alias - # so stop looking - break - + alias = _preferred_alias(el['artist'].get('alias-list', ())) + # An artist. - if chosen_alias is not None: - cur_artist_name = chosen_alias['alias'] + if alias: + cur_artist_name = alias['alias'] else: cur_artist_name = el['artist']['name'] artist_parts.append(cur_artist_name) # Artist sort name. - if chosen_alias is not None: - artist_sort_parts.append(chosen_alias['sort-name']) + if alias: + artist_sort_parts.append(alias['sort-name']) elif 'sort-name' in el['artist']: artist_sort_parts.append(el['artist']['sort-name']) else: From 0045880f58e7d04e272b5ff60d99f60493788143 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 26 Feb 2013 14:38:34 -0800 Subject: [PATCH 03/88] change option name to "languages"; docs (#102) --- beets/autotag/mb.py | 2 +- beets/config_default.yaml | 2 +- docs/changelog.rst | 9 +++++++++ docs/reference/config.rst | 10 ++++++++++ 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/beets/autotag/mb.py b/beets/autotag/mb.py index 0a47e0bb5..833542b92 100644 --- a/beets/autotag/mb.py +++ b/beets/autotag/mb.py @@ -75,7 +75,7 @@ def _preferred_alias(aliases): aliases = [a for a in aliases if 'locale' in a] # Search configured locales in order. - for locale in config['import']['artist_aliases'].as_str_seq(): + for locale in config['import']['languages'].as_str_seq(): # Find matching aliases for this locale. matches = [a for a in aliases if a['locale'] == locale] if not matches: diff --git a/beets/config_default.yaml b/beets/config_default.yaml index a0830bce9..293dad223 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -16,7 +16,7 @@ import: quiet: no singletons: no default_action: apply - artist_aliases: [] + languages: [] clutter: ["Thumbs.DB", ".DS_Store"] ignore: [".*", "*~", "System Volume Information"] diff --git a/docs/changelog.rst b/docs/changelog.rst index 086cc4b23..b816ab07a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,15 @@ Changelog 1.1b3 (in development) ---------------------- +New configuration options: + +* :ref:`languages` controls the preferred languages when selecting an alias + from MusicBrainz. This feature requires `python-musicbrainz-ngs`_ 0.3 or + later, which (at the time of this writing) is not yet released. Thanks to + Sam Doshi. + +Other stuff: + * :doc:`/plugins/echonest_tempo`: API errors now issue a warning instead of exiting with an exception. We also avoid an error when track metadata contains newlines. diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 655179637..5a325b172 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -317,6 +317,16 @@ should be the *default* when selecting an action for a given match. This is the action that will be taken when you type return without an option letter. The default is ``apply``. +.. _languages: + +languages +~~~~~~~~~ + +A list of locale names to search for preferred aliases. For example, setting +this to "en" uses the transliterated artist name "Pyotr Ilyich Tchaikovsky" +instead of the Cyrillic script for the composer's name when tagging from +MusicBrainz. Defaults to an empty list, meaning that no language is preferred. + .. _musicbrainz-config: MusicBrainz Options From 6ab9b55845785c8452017eaf73ab2c519913be11 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 26 Feb 2013 16:40:37 -0800 Subject: [PATCH 04/88] link to GitHub Issues --- docs/guides/tagger.rst | 2 +- docs/index.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guides/tagger.rst b/docs/guides/tagger.rst index c962f40e6..af9f3d04d 100644 --- a/docs/guides/tagger.rst +++ b/docs/guides/tagger.rst @@ -255,7 +255,7 @@ MusicBrainz---so consider adding the data yourself. If you think beets is ignoring an album that's listed in MusicBrainz, please `file a bug report`_. -.. _file a bug report: http://code.google.com/p/beets/issues/entry +.. _file a bug report: https://github.com/sampsyo/beets/issues I Hope That Makes Sense ----------------------- diff --git a/docs/index.rst b/docs/index.rst index 4ee7995d4..b7e274d98 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -18,7 +18,7 @@ where you think this documentation can be improved. .. _beets: http://beets.radbox.org/ .. _email the author: mailto:adrian@radbox.org -.. _file a bug: http://code.google.com/p/beets/issues/entry +.. _file a bug: https://github.com/sampsyo/beets/issues Contents -------- From 706c4fb7f6788d85ac93b24e2ae9c149baa18f5f Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 26 Feb 2013 20:36:29 -0800 Subject: [PATCH 05/88] import.detail config option (GC-263) --- beets/config_default.yaml | 1 + beets/ui/commands.py | 6 +++++- docs/changelog.rst | 2 ++ docs/reference/config.rst | 10 ++++++++++ 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/beets/config_default.yaml b/beets/config_default.yaml index 293dad223..2bd799612 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -17,6 +17,7 @@ import: singletons: no default_action: apply languages: [] + detail: no clutter: ["Thumbs.DB", ".DS_Store"] ignore: [".*", "*~", "System Volume Information"] diff --git a/beets/ui/commands.py b/beets/ui/commands.py index b50341532..53a9505cd 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -226,13 +226,17 @@ def show_change(cur_artist, cur_album, match): if lhs != rhs: lines.append((lhs, rhs, lhs_width)) + elif config['import']['detail']: + lines.append((lhs, '', lhs_width)) # Print each track in two columns, or across two lines. col_width = (ui.term_width() - len(''.join([' * ', ' -> ']))) // 2 if lines: max_width = max(w for _, _, w in lines) for lhs, rhs, lhs_width in lines: - if max_width > col_width: + if not rhs: + print_(u' * {0}'.format(lhs)) + elif max_width > col_width: print_(u' * %s ->\n %s' % (lhs, rhs)) else: pad = max_width - lhs_width diff --git a/docs/changelog.rst b/docs/changelog.rst index b816ab07a..6f1379a56 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -10,6 +10,8 @@ New configuration options: from MusicBrainz. This feature requires `python-musicbrainz-ngs`_ 0.3 or later, which (at the time of this writing) is not yet released. Thanks to Sam Doshi. +* :ref:`detail` enables a mode where all tracks are listed in the importer UI, + as opposed to only changed tracks. Other stuff: diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 5a325b172..a539a3a9d 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -327,6 +327,16 @@ this to "en" uses the transliterated artist name "Pyotr Ilyich Tchaikovsky" instead of the Cyrillic script for the composer's name when tagging from MusicBrainz. Defaults to an empty list, meaning that no language is preferred. +.. _detail: + +detail +~~~~~~ + +Whether the importer UI should show detailed information about each match it +finds. When enabled, this mode prints out the title of every track, regardless +of whether it matches the original metadata. (The default behavior only shows +changes.) Default: ``no``. + .. _musicbrainz-config: MusicBrainz Options From f268aae17fd528c909a5e9e1dfcee4d22540dcc2 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 26 Feb 2013 20:59:52 -0800 Subject: [PATCH 06/88] add --flat option to import command (GC-202) --- beets/config_default.yaml | 1 + beets/importer.py | 9 +++++++++ beets/ui/commands.py | 2 ++ docs/changelog.rst | 3 +++ docs/reference/cli.rst | 6 ++++++ 5 files changed, 21 insertions(+) diff --git a/beets/config_default.yaml b/beets/config_default.yaml index 2bd799612..210d7f1db 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -18,6 +18,7 @@ import: default_action: apply languages: [] detail: no + flat: no clutter: ["Thumbs.DB", ".DS_Store"] ignore: [".*", "*~", "System Volume Information"] diff --git a/beets/importer.py b/beets/importer.py index a6ff10fbc..10c66f85f 100644 --- a/beets/importer.py +++ b/beets/importer.py @@ -564,6 +564,15 @@ def read_tasks(session): yield ImportTask.item_task(item) continue + # A flat album import merges all items into one album. + if config['import']['flat'] and not config['import']['singletons']: + all_items = [] + for _, items in autotag.albums_in_dir(toppath): + all_items += items + yield ImportTask(toppath, toppath, all_items) + yield ImportTask.done_sentinel(toppath) + continue + # Produce paths under this directory. if _resume(): resume_dir = resume_dirs.get(toppath) diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 53a9505cd..39702a85e 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -713,6 +713,8 @@ import_cmd.parser.add_option('-i', '--incremental', dest='incremental', action='store_true', help='skip already-imported directories') import_cmd.parser.add_option('-I', '--noincremental', dest='incremental', action='store_false', help='do not skip already-imported directories') +import_cmd.parser.add_option('--flat', dest='flat', + action='store_true', help='import an entire tree as a single album') def import_func(lib, opts, args): config['import'].set_args(opts) diff --git a/docs/changelog.rst b/docs/changelog.rst index 6f1379a56..156c30df5 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -12,6 +12,9 @@ New configuration options: Sam Doshi. * :ref:`detail` enables a mode where all tracks are listed in the importer UI, as opposed to only changed tracks. +* The ``--flat`` option to the ``beet import`` command treats an entire + directory tree of music files as a single album. This can help in situations + where a multi-disc album is split across multiple directories. Other stuff: diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index ca718cea5..2add6aaba 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -93,6 +93,12 @@ right now; this is something we need to work on. Read the instead want to import individual, non-album tracks, use the *singleton* mode by supplying the ``-s`` option. +* If you have an album that's split across several directories under a common + top directory, use the ``--flat`` option. This takes all the music files + under the directory (recursively) and treats them as a single large album + instead of as one album per directory. This can help with your more stubborn + multi-disc albums. + .. only:: html Reimporting From e7b527553822ded5cfd1c49109e6086f3c4b958d Mon Sep 17 00:00:00 2001 From: Sam Doshi Date: Wed, 27 Feb 2013 08:43:32 +0000 Subject: [PATCH 07/88] tag_album does not take timid as an argument see SHA: 75d43270e881a7ecf16de4474b5fec54722ebf2e --- beets/importer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beets/importer.py b/beets/importer.py index 10c66f85f..a5371fabf 100644 --- a/beets/importer.py +++ b/beets/importer.py @@ -643,7 +643,7 @@ def initial_lookup(session): log.debug('Looking up: %s' % displayable_path(task.paths)) task.set_candidates( - *autotag.tag_album(task.items, config['import']['timid'].get(bool)) + *autotag.tag_album(task.items) ) def user_query(session): From 70b5b3ad4435516b5ef52ad611190b8efd9cdf4f Mon Sep 17 00:00:00 2001 From: Sam Doshi Date: Wed, 27 Feb 2013 08:58:06 +0000 Subject: [PATCH 08/88] go to next locale if no match in _preferred_alias we should continue here rather than return so that we continue to search for matches --- beets/autotag/mb.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/beets/autotag/mb.py b/beets/autotag/mb.py index 833542b92..7b432d653 100644 --- a/beets/autotag/mb.py +++ b/beets/autotag/mb.py @@ -78,8 +78,9 @@ def _preferred_alias(aliases): for locale in config['import']['languages'].as_str_seq(): # Find matching aliases for this locale. matches = [a for a in aliases if a['locale'] == locale] + # Skip to the next locale if we have no matches if not matches: - return + continue # Find the aliases that have the primary flag set. primaries = [a for a in matches if 'primary' in a] From 8394619b994e04d908faf98dbc8ab6c5de252f41 Mon Sep 17 00:00:00 2001 From: Sam Doshi Date: Wed, 27 Feb 2013 09:17:42 +0000 Subject: [PATCH 09/88] add unit tests for aliases (#102) --- test/test_mb.py | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/test/test_mb.py b/test/test_mb.py index ebecc5e7d..e23201706 100644 --- a/test/test_mb.py +++ b/test/test_mb.py @@ -16,6 +16,7 @@ """ from _common import unittest from beets.autotag import mb +from beets import config class MBAlbumInfoTest(unittest.TestCase): def _make_release(self, date_str='2009', tracks=None): @@ -286,6 +287,18 @@ class ArtistFlatteningTest(unittest.TestCase): 'name': 'CREDIT' + suffix, } + def _add_alias(self, credit_dict, suffix='', locale='', primary=False): + alias = { + 'alias': 'ALIAS' + suffix, + 'locale': locale, + 'sort-name': 'ALIASSORT' + suffix + } + if primary: + alias['primary'] = 'primary' + if 'alias-list' not in credit_dict['artist']: + credit_dict['artist']['alias-list'] = [] + credit_dict['artist']['alias-list'].append(alias) + def test_single_artist(self): a, s, c = mb._flatten_artist_credit([self._credit_dict()]) self.assertEqual(a, 'NAME') @@ -300,6 +313,38 @@ class ArtistFlatteningTest(unittest.TestCase): self.assertEqual(s, 'SORTa AND SORTb') self.assertEqual(c, 'CREDITa AND CREDITb') + def test_alias(self): + credit_dict = self._credit_dict() + self._add_alias(credit_dict, suffix='en', locale='en') + self._add_alias(credit_dict, suffix='en_GB', locale='en_GB') + self._add_alias(credit_dict, suffix='fr', locale='fr') + self._add_alias(credit_dict, suffix='fr_P', locale='fr', primary=True) + + # test no alias + config['import']['languages'] = [''] + flat = mb._flatten_artist_credit([credit_dict]) + self.assertEqual(flat, ('NAME', 'SORT', 'CREDIT')) + + # test en + config['import']['languages'] = ['en'] + flat = mb._flatten_artist_credit([credit_dict]) + self.assertEqual(flat, ('ALIASen', 'ALIASSORTen', 'CREDIT')) + + # test en_GB en + config['import']['languages'] = ['en_GB', 'en'] + flat = mb._flatten_artist_credit([credit_dict]) + self.assertEqual(flat, ('ALIASen_GB', 'ALIASSORTen_GB', 'CREDIT')) + + # test en en_GB + config['import']['languages'] = ['en', 'en_GB'] + flat = mb._flatten_artist_credit([credit_dict]) + self.assertEqual(flat, ('ALIASen', 'ALIASSORTen', 'CREDIT')) + + # test fr primary + config['import']['languages'] = ['fr'] + flat = mb._flatten_artist_credit([credit_dict]) + self.assertEqual(flat, ('ALIASfr_P', 'ALIASSORTfr_P', 'CREDIT')) + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) From c2e6150af5d0b58e399c6d099cb9f6f65712323f Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Wed, 27 Feb 2013 10:21:14 -0800 Subject: [PATCH 10/88] changelog/thanks for #104 --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 156c30df5..0956a8969 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -30,6 +30,8 @@ Other stuff: * Fix a crash when iTunes Sound Check tags contained invalid data. * Fix an error when the configuration file (``config.yaml``) is completely empty. +* Fix an error introduced in 1.1b1 when importing using timid mode. Thanks to + Sam Doshi. 1.1b2 (February 16, 2013) ------------------------- From c31eabe95cb603c9cc60b037143833fe5710abf2 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Wed, 27 Feb 2013 10:30:24 -0800 Subject: [PATCH 11/88] convert: add missing syspath call (closes #106) --- beetsplug/convert.py | 2 +- docs/changelog.rst | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 8ff078bed..aee2f55f0 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -53,7 +53,7 @@ def convert_item(lib, dest_dir): dest = os.path.join(dest_dir, lib.destination(item, fragment=True)) dest = os.path.splitext(dest)[0] + '.mp3' - if os.path.exists(dest): + if os.path.exists(util.syspath(dest)): log.info(u'Skipping {0} (target file exists)'.format( util.displayable_path(item.path) )) diff --git a/docs/changelog.rst b/docs/changelog.rst index 0956a8969..316814836 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -32,6 +32,8 @@ Other stuff: empty. * Fix an error introduced in 1.1b1 when importing using timid mode. Thanks to Sam Doshi. +* :doc:`/plugins/convert`: Fix a bug when creating files with Unicode + pathnames. 1.1b2 (February 16, 2013) ------------------------- From d71737114a301fe6f13d2c2ad28fcadd15527ac3 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 28 Feb 2013 10:00:26 -0800 Subject: [PATCH 12/88] format None values as the empty string Fix due to @pscn. Includes test. Closes #108. --- beets/library.py | 2 ++ docs/changelog.rst | 2 ++ test/test_db.py | 4 ++++ 3 files changed, 8 insertions(+) diff --git a/beets/library.py b/beets/library.py index dd2a1ee7f..f07ea7a53 100644 --- a/beets/library.py +++ b/beets/library.py @@ -218,6 +218,8 @@ def format_for_path(value, key=None, pathmod=None): elif key == 'samplerate': # Sample rate formatted as kHz. value = u'%ikHz' % ((value or 0) // 1000) + elif value is None: + value = u'' else: value = unicode(value) diff --git a/docs/changelog.rst b/docs/changelog.rst index 316814836..26e45029b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -24,6 +24,8 @@ Other stuff: * When the importer encounters an error (insufficient permissions, for example) when walking a directory tree, it now logs an error instead of crashing. +* In path formats, null database values now expand to the empty string instead + of the string "None". * Add "System Volume Information" (an internal directory found on some Windows filesystems) to the default ignore list. * Fix a crash when ReplayGain values were set to null. diff --git a/test/test_db.py b/test/test_db.py index d4dd30092..4ee6ea419 100644 --- a/test/test_db.py +++ b/test/test_db.py @@ -362,6 +362,10 @@ class DestinationTest(unittest.TestCase): val = beets.library.format_for_path(12345, 'samplerate', posixpath) self.assertEqual(val, u'12kHz') + def test_component_sanitize_none(self): + val = beets.library.format_for_path(None, 'foo', posixpath) + self.assertEqual(val, u'') + def test_artist_falls_back_to_albumartist(self): self.i.artist = '' self.i.albumartist = 'something' From 698e54edbc9154e8645eb44f03057167181093e6 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 28 Feb 2013 10:14:21 -0800 Subject: [PATCH 13/88] fix bytes literals causing a Unidecode warning This would trigger a warning in Unidecode when metadata was missing (which is the only case when those empty-string literals are used). Closes #109, which is a different fix for the same problem. --- beets/autotag/match.py | 4 ++-- docs/changelog.rst | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/beets/autotag/match.py b/beets/autotag/match.py index 85d64c319..839c2b56d 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -257,8 +257,8 @@ def distance(items, album_info, mapping): `album_info.tracks`. """ cur_artist, cur_album, _ = current_metadata(items) - cur_artist = cur_artist or '' - cur_album = cur_album or '' + cur_artist = cur_artist or u'' + cur_album = cur_album or u'' # These accumulate the possible distance components. The final # distance will be dist/dist_max. diff --git a/docs/changelog.rst b/docs/changelog.rst index 26e45029b..4deda0c6f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -36,6 +36,8 @@ Other stuff: Sam Doshi. * :doc:`/plugins/convert`: Fix a bug when creating files with Unicode pathnames. +* Fix a spurious warning from the Unidecode module when matching albums that + are missing all metadata. 1.1b2 (February 16, 2013) ------------------------- From 1fbbe6154698ce50f1a7e8d32af9a6376e2c7ede Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 28 Feb 2013 10:43:02 -0800 Subject: [PATCH 14/88] mbcollection: human-readable MB exceptions Fixes #107 by wrapping the API invocation function with exception handlers. --- beetsplug/mbcollection.py | 21 +++++++++++++++++---- docs/changelog.rst | 2 ++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/beetsplug/mbcollection.py b/beetsplug/mbcollection.py index 90e2d3578..9fb532978 100644 --- a/beetsplug/mbcollection.py +++ b/beetsplug/mbcollection.py @@ -23,6 +23,18 @@ from musicbrainzngs import musicbrainz SUBMISSION_CHUNK_SIZE = 200 +def mb_request(*args, **kwargs): + """Send a MusicBrainz API request and process exceptions. + """ + try: + return musicbrainz._mb_request(*args, **kwargs) + except musicbrainzngs.AuthenticationError: + raise ui.UserError('authentication with MusicBrainz failed') + except musicbrainzngs.ResponseError as exc: + raise ui.UserError('MusicBrainz API error: {0}'.format(exc)) + except musicbrainzngs.UsageError: + raise ui.UserError('MusicBrainz credentials missing') + def submit_albums(collection_id, release_ids): """Add all of the release IDs to the indicated collection. Multiple requests are made if there are many release IDs to submit. @@ -30,15 +42,16 @@ def submit_albums(collection_id, release_ids): for i in range(0, len(release_ids), SUBMISSION_CHUNK_SIZE): chunk = release_ids[i:i+SUBMISSION_CHUNK_SIZE] releaselist = ";".join(chunk) - musicbrainz._mb_request( + mb_request( "collection/%s/releases/%s" % (collection_id, releaselist), - 'PUT', True, True, body='foo') + 'PUT', True, True, body='foo' + ) # A non-empty request body is required to avoid a 411 "Length # Required" error from the MB server. def update_collection(lib, opts, args): # Get the collection to modify. - collections = musicbrainz._mb_request('collection', 'GET', True, True) + collections = mb_request('collection', 'GET', True, True) if not collections['collection-list']: raise ui.UserError('no collections exist for user') collection_id = collections['collection-list'][0]['id'] @@ -60,7 +73,7 @@ class MusicBrainzCollectionPlugin(BeetsPlugin): super(MusicBrainzCollectionPlugin, self).__init__() musicbrainzngs.auth( config['musicbrainz']['user'].get(unicode), - config['musicbrainz']['pass'].get(unicode) + config['musicbrainz']['pass'].get(unicode), ) def commands(self): diff --git a/docs/changelog.rst b/docs/changelog.rst index 4deda0c6f..f7ad1b07b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -38,6 +38,8 @@ Other stuff: pathnames. * Fix a spurious warning from the Unidecode module when matching albums that are missing all metadata. +* :doc:`/plugins/mbcollection`: Show friendly, human-readable errors when + MusicBrainz exceptions occur. 1.1b2 (February 16, 2013) ------------------------- From ef22ea5183234fb851e7533975cba5f281fa384d Mon Sep 17 00:00:00 2001 From: Jakob Schnitzer Date: Fri, 1 Mar 2013 16:16:28 +0100 Subject: [PATCH 15/88] convert: add missing util.displayable_path --- beetsplug/convert.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index aee2f55f0..3ce3e34a4 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -39,7 +39,8 @@ def encode(source, dest): 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)) + log.info(u'Encoding {0} failed. Cleaning up...' + .format(util.displayable_path(source))) util.remove(dest) util.prune_dirs(os.path.dirname(dest)) return From 2fd3ad53622d88e1df91580b073f423645ab0447 Mon Sep 17 00:00:00 2001 From: Jakob Schnitzer Date: Fri, 1 Mar 2013 15:47:44 +0100 Subject: [PATCH 16/88] initial version of the mbsync plugin this plugin provides a faster way to query new metadata from musicbrainz. (instead of having to 're-import' the files) Currently it lacks all forms of documentation and will only work for album queries. not really tested so far so be careful --- beetsplug/mbsync.py | 100 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 beetsplug/mbsync.py diff --git a/beetsplug/mbsync.py b/beetsplug/mbsync.py new file mode 100644 index 000000000..fd7c41323 --- /dev/null +++ b/beetsplug/mbsync.py @@ -0,0 +1,100 @@ +# This file is part of beets. +# Copyright 2013, Jakob Schnitzer. +# +# 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. + +"""Update local library from MusicBrainz +""" +import logging + +from beets.plugins import BeetsPlugin +from beets import autotag, library, ui, util + +log = logging.getLogger('beets') + + +def mbsync_func(lib, opts, args): + #album = opts.album + album = True + move = True + pretend = opts.pretend + with lib.transaction(): + # Right now this only works for albums.... + _, albums = ui.commands._do_query(lib, ui.decargs(args), album) + + for a in albums: + if not a.mb_albumid: + log.info(u'Skipping album {0}: has no mb_albumid'.format(a.id)) + continue + + items = list(a.items()) + for item in items: + item.old_data = dict(item.record) + + cur_artist, cur_album, candidates, _ = \ + autotag.match.tag_album(items, search_id=a.mb_albumid) + match = candidates[0] # There should only be one match! + # ui.commands.show_change(cur_artist, cur_album, match) + autotag.apply_metadata(match.info, match.mapping) + + for item in items: + changes = {} + for key in library.ITEM_KEYS_META: + if item.dirty[key]: + changes[key] = item.old_data[key], getattr(item, key) + if changes: + # Something changed. + ui.print_obj(item, lib) + for key, (oldval, newval) in changes.iteritems(): + ui.commands._showdiff(key, oldval, newval) + + # If we're just pretending, then don't move or save. + if pretend: + continue + + # Move the item if it's in the library. + if move and lib.directory in util.ancestry(item.path): + lib.move(item) + lib.store(item) + + if pretend or a.id is None: # pretend or Singleton + continue + + # Update album structure to reflect an item in it. + for key in library.ALBUM_KEYS_ITEM: + setattr(a, key, getattr(items[0], key)) + + # Move album art (and any inconsistent items). + if move and lib.directory in util.ancestry(items[0].path): + log.debug(u'moving album {0}'.format(a.id)) + a.move() + + +class MBSyncPlugin(BeetsPlugin): + def __init__(self): + super(MBSyncPlugin, self).__init__() + + def commands(self): + cmd = ui.Subcommand('mbsync', + help='update metadata from musicbrainz') + #cmd.parser.add_option('-a', '--album', action='store_true', + # help='choose albums instead of tracks') + cmd.parser.add_option('-p', '--pretend', action='store_true', + help='show all changes but do nothing') + cmd.parser.add_option('-M', '--nomove', action='store_false', + default=True, dest='move', + help="don't move files in library") + cmd.parser.add_option('-W', '--nowrite', action='store_false', + default=True, dest='move', + help="don't write updated metadata to files") + cmd.func = mbsync_func + return [cmd] From cd7305d4872b1316c5eb4c6e2e1a61018defc934 Mon Sep 17 00:00:00 2001 From: Jakob Schnitzer Date: Sun, 3 Mar 2013 11:39:34 +0100 Subject: [PATCH 17/88] mbsync: write metadata to files... --- beetsplug/mbsync.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/beetsplug/mbsync.py b/beetsplug/mbsync.py index fd7c41323..6b453bb2e 100644 --- a/beetsplug/mbsync.py +++ b/beetsplug/mbsync.py @@ -24,12 +24,12 @@ log = logging.getLogger('beets') def mbsync_func(lib, opts, args): #album = opts.album - album = True - move = True + move = opts.move pretend = opts.pretend + write = opts.write with lib.transaction(): # Right now this only works for albums.... - _, albums = ui.commands._do_query(lib, ui.decargs(args), album) + albums = lib.albums(ui.decargs(args)) for a in albums: if not a.mb_albumid: @@ -43,7 +43,6 @@ def mbsync_func(lib, opts, args): cur_artist, cur_album, candidates, _ = \ autotag.match.tag_album(items, search_id=a.mb_albumid) match = candidates[0] # There should only be one match! - # ui.commands.show_change(cur_artist, cur_album, match) autotag.apply_metadata(match.info, match.mapping) for item in items: @@ -64,9 +63,12 @@ def mbsync_func(lib, opts, args): # Move the item if it's in the library. if move and lib.directory in util.ancestry(item.path): lib.move(item) + + if write: + item.write() lib.store(item) - if pretend or a.id is None: # pretend or Singleton + if pretend: continue # Update album structure to reflect an item in it. @@ -94,7 +96,7 @@ class MBSyncPlugin(BeetsPlugin): default=True, dest='move', help="don't move files in library") cmd.parser.add_option('-W', '--nowrite', action='store_false', - default=True, dest='move', + default=True, dest='write', help="don't write updated metadata to files") cmd.func = mbsync_func return [cmd] From 78a99c23fa58e3fa48deb50147a15eaa3bac0fbd Mon Sep 17 00:00:00 2001 From: Jakob Schnitzer Date: Sun, 3 Mar 2013 12:25:53 +0100 Subject: [PATCH 18/88] mbsync: documentation --- docs/plugins/index.rst | 2 ++ docs/plugins/mbsync.rst | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 docs/plugins/mbsync.rst diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 2217f4d91..3c9eb4d9f 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -60,6 +60,7 @@ disabled by default, but you can turn them on as described above. convert info smartplaylist + mbsync Autotagger Extensions '''''''''''''''''''''' @@ -73,6 +74,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:`mbsync`: Fetch updated metadata from MusicBrainz * :doc:`fetchart`: Fetch album cover art from various sources. * :doc:`embedart`: Embed album art images into files' metadata. * :doc:`replaygain`: Calculate volume normalization for players that support it. diff --git a/docs/plugins/mbsync.rst b/docs/plugins/mbsync.rst new file mode 100644 index 000000000..47312646a --- /dev/null +++ b/docs/plugins/mbsync.rst @@ -0,0 +1,22 @@ +MBSync Plugin +============= + +The ``mbsync`` lets you fetch metadata from MusicBrainz for albums that already +have MusicBrainz IDs. This is useful for updating tags as they are fixed in the +MusicBrainz database, or when you change your mind about some config options +that change how tags are written to files. If you have a music library that is +already nicely tagged by a program that also uses MusicBrainz like Picard, this +can speed up the initial import if you just import “as-is” and then use +``mbsync`` to get up-to-date tags that are written to the files according to +your beets configuration. + + +Usage +----- + +Enable the plugin and then run ``beet mbsync QUERY`` to fetch updated metadata +for a part of your collection. This only work for album queries at the moment. +To only preview the changes that would be made, use the ``-p`` (``--pretend``) +flag. By default all the new metadata will be written to the files and the files +will be moved according to their new metadata. This behaviour can be changed +with the ``-W`` (``--nowrite``) and ``-M`` (``--nomove``) command line options. From 3a9c9d53da9f45639dc7612c73a4da2076c1e557 Mon Sep 17 00:00:00 2001 From: Jakob Schnitzer Date: Sun, 3 Mar 2013 14:16:08 +0100 Subject: [PATCH 19/88] mbsync: add support for singletons I can't really guarantee this works right now since I have no singletons in my collection to test it --- beetsplug/mbsync.py | 49 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/beetsplug/mbsync.py b/beetsplug/mbsync.py index 6b453bb2e..48d102df8 100644 --- a/beetsplug/mbsync.py +++ b/beetsplug/mbsync.py @@ -23,13 +23,48 @@ log = logging.getLogger('beets') def mbsync_func(lib, opts, args): - #album = opts.album move = opts.move pretend = opts.pretend write = opts.write + if opts.album and opts.singleton: + return + with lib.transaction(): - # Right now this only works for albums.... - albums = lib.albums(ui.decargs(args)) + singletons = [item for item in lib.items(ui.decargs(args)) + if item.album_id is None] if not opts.album else [] + albums = lib.albums(ui.decargs(args)) if not opts.singleton else [] + + for s in singletons: + if not s.mb_trackid: + log.info(u'Skipping singleton {0}: has no mb_trackid' + .format(s.title)) + continue + + old_data = dict(s.record) + candidates, _ = autotag.match.tag_item(s, search_id=s.mb_trackid) + match = candidates[0] + autotag.apply_item_metadata(s, match.info) + changes = {} + for key in library.ITEM_KEYS_META: + if s.dirty[key]: + changes[key] = old_data[key], getattr(s, key) + if changes: + # Something changed. + ui.print_obj(s, lib) + for key, (oldval, newval) in changes.iteritems(): + ui.commands._showdiff(key, oldval, newval) + + # If we're just pretending, then don't move or save. + if pretend: + continue + + # Move the item if it's in the library. + if move and lib.directory in util.ancestry(s.path): + lib.move(s) + + if write: + s.write() + lib.store(s) for a in albums: if not a.mb_albumid: @@ -40,7 +75,7 @@ def mbsync_func(lib, opts, args): for item in items: item.old_data = dict(item.record) - cur_artist, cur_album, candidates, _ = \ + _, _, candidates, _ = \ autotag.match.tag_album(items, search_id=a.mb_albumid) match = candidates[0] # There should only be one match! autotag.apply_metadata(match.info, match.mapping) @@ -88,8 +123,10 @@ class MBSyncPlugin(BeetsPlugin): def commands(self): cmd = ui.Subcommand('mbsync', help='update metadata from musicbrainz') - #cmd.parser.add_option('-a', '--album', action='store_true', - # help='choose albums instead of tracks') + cmd.parser.add_option('-a', '--album', action='store_true', + help='only query for albums') + cmd.parser.add_option('-s', '--singleton', action='store_true', + help='only query for singletons') cmd.parser.add_option('-p', '--pretend', action='store_true', help='show all changes but do nothing') cmd.parser.add_option('-M', '--nomove', action='store_false', From 49d3ca4f020a9375dd68d476f03e7985cf57b146 Mon Sep 17 00:00:00 2001 From: Jakob Schnitzer Date: Sun, 3 Mar 2013 14:21:34 +0100 Subject: [PATCH 20/88] mbsync: update docs --- docs/plugins/mbsync.rst | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/docs/plugins/mbsync.rst b/docs/plugins/mbsync.rst index 47312646a..95733e9d9 100644 --- a/docs/plugins/mbsync.rst +++ b/docs/plugins/mbsync.rst @@ -1,21 +1,25 @@ MBSync Plugin ============= -The ``mbsync`` lets you fetch metadata from MusicBrainz for albums that already -have MusicBrainz IDs. This is useful for updating tags as they are fixed in the -MusicBrainz database, or when you change your mind about some config options -that change how tags are written to files. If you have a music library that is -already nicely tagged by a program that also uses MusicBrainz like Picard, this -can speed up the initial import if you just import “as-is” and then use -``mbsync`` to get up-to-date tags that are written to the files according to -your beets configuration. +The ``mbsync`` lets you fetch metadata from MusicBrainz for albums and +singletons that already have MusicBrainz IDs. This is useful for updating tags +as they are fixed in the MusicBrainz database, or when you change your mind +about some config options that change how tags are written to files. If you have +a music library that is already nicely tagged by a program that also uses +MusicBrainz like Picard, this can speed up the initial import if you just import +“as-is” and then use ``mbsync`` to get up-to-date tags that are written to the +files according to your beets configuration. Usage ----- Enable the plugin and then run ``beet mbsync QUERY`` to fetch updated metadata -for a part of your collection. This only work for album queries at the moment. +for a part of your collection. By default this will use the given query to +search for albums and singletons. You can use the ``-a`` (``--album``) and +``-s`` (``--singleton``) command line flags to only search for albums or +singletons respectively. + To only preview the changes that would be made, use the ``-p`` (``--pretend``) flag. By default all the new metadata will be written to the files and the files will be moved according to their new metadata. This behaviour can be changed From d647ea0f0d7a9b12a99f2eaad3d216331226b9de Mon Sep 17 00:00:00 2001 From: Jakob Schnitzer Date: Sun, 3 Mar 2013 20:32:28 +0100 Subject: [PATCH 21/88] mbsync: a little refactoring --- beetsplug/mbsync.py | 99 +++++++++++++++-------------------------- docs/plugins/mbsync.rst | 8 ++-- 2 files changed, 41 insertions(+), 66 deletions(-) diff --git a/beetsplug/mbsync.py b/beetsplug/mbsync.py index 48d102df8..a751d4d27 100644 --- a/beetsplug/mbsync.py +++ b/beetsplug/mbsync.py @@ -22,17 +22,38 @@ from beets import autotag, library, ui, util log = logging.getLogger('beets') +def _print_and_apply_changes(lib, item, move, pretend, write): + changes = {} + for key in library.ITEM_KEYS_META: + if item.dirty[key]: + changes[key] = item.old_data[key], getattr(item, key) + if not changes: + return + + # Something changed. + ui.print_obj(item, lib) + for key, (oldval, newval) in changes.iteritems(): + ui.commands._showdiff(key, oldval, newval) + + # If we're just pretending, then don't move or save. + if not pretend: + # Move the item if it's in the library. + if move and lib.directory in util.ancestry(item.path): + lib.move(item) + + if write: + item.write() + lib.store(item) + + def mbsync_func(lib, opts, args): move = opts.move pretend = opts.pretend write = opts.write - if opts.album and opts.singleton: - return with lib.transaction(): - singletons = [item for item in lib.items(ui.decargs(args)) - if item.album_id is None] if not opts.album else [] - albums = lib.albums(ui.decargs(args)) if not opts.singleton else [] + singletons = lib.items(ui.decargs(args + ['singleton'])) + albums = lib.albums(ui.decargs(args)) for s in singletons: if not s.mb_trackid: @@ -40,31 +61,11 @@ def mbsync_func(lib, opts, args): .format(s.title)) continue - old_data = dict(s.record) + s.old_data = dict(s.record) candidates, _ = autotag.match.tag_item(s, search_id=s.mb_trackid) match = candidates[0] autotag.apply_item_metadata(s, match.info) - changes = {} - for key in library.ITEM_KEYS_META: - if s.dirty[key]: - changes[key] = old_data[key], getattr(s, key) - if changes: - # Something changed. - ui.print_obj(s, lib) - for key, (oldval, newval) in changes.iteritems(): - ui.commands._showdiff(key, oldval, newval) - - # If we're just pretending, then don't move or save. - if pretend: - continue - - # Move the item if it's in the library. - if move and lib.directory in util.ancestry(s.path): - lib.move(s) - - if write: - s.write() - lib.store(s) + _print_and_apply_changes(lib, s, move, pretend, write) for a in albums: if not a.mb_albumid: @@ -81,39 +82,17 @@ def mbsync_func(lib, opts, args): autotag.apply_metadata(match.info, match.mapping) for item in items: - changes = {} - for key in library.ITEM_KEYS_META: - if item.dirty[key]: - changes[key] = item.old_data[key], getattr(item, key) - if changes: - # Something changed. - ui.print_obj(item, lib) - for key, (oldval, newval) in changes.iteritems(): - ui.commands._showdiff(key, oldval, newval) + _print_and_apply_changes(lib, item, move, pretend, write) - # If we're just pretending, then don't move or save. - if pretend: - continue + if not pretend: + # Update album structure to reflect an item in it. + for key in library.ALBUM_KEYS_ITEM: + setattr(a, key, getattr(items[0], key)) - # Move the item if it's in the library. - if move and lib.directory in util.ancestry(item.path): - lib.move(item) - - if write: - item.write() - lib.store(item) - - if pretend: - continue - - # Update album structure to reflect an item in it. - for key in library.ALBUM_KEYS_ITEM: - setattr(a, key, getattr(items[0], key)) - - # Move album art (and any inconsistent items). - if move and lib.directory in util.ancestry(items[0].path): - log.debug(u'moving album {0}'.format(a.id)) - a.move() + # Move album art (and any inconsistent items). + if move and lib.directory in util.ancestry(items[0].path): + log.debug(u'moving album {0}'.format(a.id)) + a.move() class MBSyncPlugin(BeetsPlugin): @@ -123,10 +102,6 @@ class MBSyncPlugin(BeetsPlugin): def commands(self): cmd = ui.Subcommand('mbsync', help='update metadata from musicbrainz') - cmd.parser.add_option('-a', '--album', action='store_true', - help='only query for albums') - cmd.parser.add_option('-s', '--singleton', action='store_true', - help='only query for singletons') cmd.parser.add_option('-p', '--pretend', action='store_true', help='show all changes but do nothing') cmd.parser.add_option('-M', '--nomove', action='store_false', diff --git a/docs/plugins/mbsync.rst b/docs/plugins/mbsync.rst index 95733e9d9..4023618b0 100644 --- a/docs/plugins/mbsync.rst +++ b/docs/plugins/mbsync.rst @@ -15,10 +15,10 @@ Usage ----- Enable the plugin and then run ``beet mbsync QUERY`` to fetch updated metadata -for a part of your collection. By default this will use the given query to -search for albums and singletons. You can use the ``-a`` (``--album``) and -``-s`` (``--singleton``) command line flags to only search for albums or -singletons respectively. +for a part of your collection. Since the MusicBrainZ API allows for more +efficient queries for full albums this will by run separately for all albums and +all singletons(tracks that are not part of an album) so it will use the given +query to search for both albums and singletons. To only preview the changes that would be made, use the ``-p`` (``--pretend``) flag. By default all the new metadata will be written to the files and the files From 54e070d06bdf609ee29e9de360a5de973f9af2a6 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 3 Mar 2013 16:29:31 -0800 Subject: [PATCH 22/88] mbsync: use SingletonQuery for item updates --- beets/library.py | 62 +++++++++++++++++++++------------------------ beetsplug/mbsync.py | 13 +++++----- 2 files changed, 36 insertions(+), 39 deletions(-) diff --git a/beets/library.py b/beets/library.py index f07ea7a53..390d4e1dd 100644 --- a/beets/library.py +++ b/beets/library.py @@ -798,6 +798,33 @@ class ResultIterator(object): row = self.rowiter.next() # May raise StopIteration. return Item(row) +def get_query(val, album=False): + """Takes a value which may be None, a query string, a query string + list, or a Query object, and returns a suitable Query object. album + determines whether the query is to match items or albums. + """ + if album: + default_fields = ALBUM_DEFAULT_FIELDS + all_keys = ALBUM_KEYS + else: + default_fields = ITEM_DEFAULT_FIELDS + all_keys = ITEM_KEYS + + # Convert a single string into a list of space-separated + # criteria. + if isinstance(val, basestring): + val = val.split() + + if val is None: + return TrueQuery() + elif isinstance(val, list) or isinstance(val, tuple): + return AndQuery.from_strings(val, default_fields, all_keys) + elif isinstance(val, Query): + return val + else: + raise ValueError('query must be None or have type Query or str') + + # An abstract library. @@ -809,37 +836,6 @@ class BaseLibrary(object): raise NotImplementedError - # Helpers. - - @classmethod - def _get_query(cls, val=None, album=False): - """Takes a value which may be None, a query string, a query - string list, or a Query object, and returns a suitable Query - object. album determines whether the query is to match items - or albums. - """ - if album: - default_fields = ALBUM_DEFAULT_FIELDS - all_keys = ALBUM_KEYS - else: - default_fields = ITEM_DEFAULT_FIELDS - all_keys = ITEM_KEYS - - # Convert a single string into a list of space-separated - # criteria. - if isinstance(val, basestring): - val = val.split() - - if val is None: - return TrueQuery() - elif isinstance(val, list) or isinstance(val, tuple): - return AndQuery.from_strings(val, default_fields, all_keys) - elif isinstance(val, Query): - return val - elif not isinstance(val, Query): - raise ValueError('query must be None or have type Query or str') - - # Basic operations. def add(self, item, copy=False): @@ -1358,7 +1354,7 @@ class Library(BaseLibrary): # Querying. def albums(self, query=None, artist=None): - query = self._get_query(query, True) + query = get_query(query, True) if artist is not None: # "Add" the artist to the query. query = AndQuery((query, MatchQuery('albumartist', artist))) @@ -1372,7 +1368,7 @@ class Library(BaseLibrary): return [Album(self, dict(res)) for res in rows] def items(self, query=None, artist=None, album=None, title=None): - queries = [self._get_query(query, False)] + queries = [get_query(query, False)] if artist is not None: queries.append(MatchQuery('artist', artist)) if album is not None: diff --git a/beetsplug/mbsync.py b/beetsplug/mbsync.py index a751d4d27..44a1a8771 100644 --- a/beetsplug/mbsync.py +++ b/beetsplug/mbsync.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. -"""Update local library from MusicBrainz +"""Update library's tags using MusicBrainz. """ import logging @@ -52,10 +52,10 @@ def mbsync_func(lib, opts, args): write = opts.write with lib.transaction(): - singletons = lib.items(ui.decargs(args + ['singleton'])) - albums = lib.albums(ui.decargs(args)) - - for s in singletons: + # Process matching singletons. + singletons_query = library.get_query(ui.decargs(args), False) + singletons_query.subqueries.append(library.SingletonQuery(True)) + for s in lib.items(singletons_query): if not s.mb_trackid: log.info(u'Skipping singleton {0}: has no mb_trackid' .format(s.title)) @@ -67,7 +67,8 @@ def mbsync_func(lib, opts, args): autotag.apply_item_metadata(s, match.info) _print_and_apply_changes(lib, s, move, pretend, write) - for a in albums: + # Process matching albums. + for a in lib.albums(ui.decargs(args)): if not a.mb_albumid: log.info(u'Skipping album {0}: has no mb_albumid'.format(a.id)) continue From 5f3ebde6bb09dc1acfd7686695217092d8a4759b Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 3 Mar 2013 16:41:48 -0800 Subject: [PATCH 23/88] mbsync: docs/changelog As discussed on #115, this has the "reimport" docs refer to the mbsync plugin. --- docs/changelog.rst | 4 ++++ docs/plugins/mbsync.rst | 39 +++++++++++++++++++++++---------------- docs/reference/cli.rst | 12 ++++++++---- 3 files changed, 35 insertions(+), 20 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index f7ad1b07b..49ce4edfa 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -18,6 +18,10 @@ New configuration options: Other stuff: +* A new :doc:`/plugins/mbsync` provides a command that looks up each item and + track in MusicBrainz and updates your library to reflect it. This can help + you easily correct errors that have been fixed in the MB database. Thanks to + Jakob Schnitzer. * :doc:`/plugins/echonest_tempo`: API errors now issue a warning instead of exiting with an exception. We also avoid an error when track metadata contains newlines. diff --git a/docs/plugins/mbsync.rst b/docs/plugins/mbsync.rst index 4023618b0..764be9d86 100644 --- a/docs/plugins/mbsync.rst +++ b/docs/plugins/mbsync.rst @@ -1,26 +1,33 @@ MBSync Plugin ============= -The ``mbsync`` lets you fetch metadata from MusicBrainz for albums and -singletons that already have MusicBrainz IDs. This is useful for updating tags -as they are fixed in the MusicBrainz database, or when you change your mind -about some config options that change how tags are written to files. If you have -a music library that is already nicely tagged by a program that also uses -MusicBrainz like Picard, this can speed up the initial import if you just import -“as-is” and then use ``mbsync`` to get up-to-date tags that are written to the -files according to your beets configuration. +This plugin provides the ``mbsync`` command, which lets you fetch metadata +from MusicBrainz for albums and tracks that already have MusicBrainz IDs. This +is useful for updating tags as they are fixed in the MusicBrainz database, or +when you change your mind about some config options that change how tags are +written to files. If you have a music library that is already nicely tagged by +a program that also uses MusicBrainz like Picard, this can speed up the +initial import if you just import "as-is" and then use ``mbsync`` to get +up-to-date tags that are written to the files according to your beets +configuration. Usage ----- Enable the plugin and then run ``beet mbsync QUERY`` to fetch updated metadata -for a part of your collection. Since the MusicBrainZ API allows for more -efficient queries for full albums this will by run separately for all albums and -all singletons(tracks that are not part of an album) so it will use the given -query to search for both albums and singletons. +for a part of your collection (or omit the query to run over your whole +library). -To only preview the changes that would be made, use the ``-p`` (``--pretend``) -flag. By default all the new metadata will be written to the files and the files -will be moved according to their new metadata. This behaviour can be changed -with the ``-W`` (``--nowrite``) and ``-M`` (``--nomove``) command line options. +This plugin treats albums and singletons (non-album tracks) separately. It +first processes all matching singletons and then proceeds on to full albums. +The same query is used to search for both kinds of entities. + +The command has a few command-line options: + +* To preview the changes that would be made without applying them, use the + ``-p`` (``--pretend``) flag. +* By default all the new metadata will be written to the files and the files + will be moved according to their new metadata. This behavior can be changed + with the ``-W`` (``--nowrite``) and ``-M`` (``--nomove``) command line + options. diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index 2add6aaba..575c92968 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -105,10 +105,9 @@ right now; this is something we need to work on. Read the ^^^^^^^^^^^ The ``import`` command can also be used to "reimport" music that you've - already added to your library. This is useful for updating tags as they are - fixed in the MusicBrainz database, for when you change your mind about some - selections you made during the initial import, or if you prefer to import - everything "as-is" and then correct tags later. + already added to your library. This is useful when you change your mind + about some selections you made during the initial import, or if you prefer + to import everything "as-is" and then correct tags later. Just point the ``beet import`` command at a directory of files that are already catalogged in your library. Beets will automatically detect this @@ -127,6 +126,11 @@ right now; this is something we need to work on. Read the or full albums. If you want to retag your whole library, just supply a null query, which matches everything: ``beet import -L`` + Note that, if you just want to update your files' tags according to + changes in the MusicBrainz database, the :doc:`/plugins/mbsync` is a + better choice. Reimporting uses the full matching machinery to guess + metadata matches; ``mbsync`` just relies on MusicBrainz IDs. + .. _list-cmd: list From a8d999a1014d7c001d5b4c4751dd969383d19f44 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 3 Mar 2013 16:52:14 -0800 Subject: [PATCH 24/88] mbsync: split album/item functions; shorter txns The main change here is to use shorter transactions -- one per matching entity -- rather than one large one. This avoids very long transactions when the network happens to move slowly. --- beetsplug/mbsync.py | 72 +++++++++++++++++++++++++++------------------ 1 file changed, 43 insertions(+), 29 deletions(-) diff --git a/beetsplug/mbsync.py b/beetsplug/mbsync.py index 44a1a8771..96dc79319 100644 --- a/beetsplug/mbsync.py +++ b/beetsplug/mbsync.py @@ -46,42 +46,44 @@ def _print_and_apply_changes(lib, item, move, pretend, write): lib.store(item) -def mbsync_func(lib, opts, args): - move = opts.move - pretend = opts.pretend - write = opts.write +def mbsync_singletons(lib, query, move, pretend, write): + """Synchronize matching singleton items. + """ + singletons_query = library.get_query(query, False) + singletons_query.subqueries.append(library.SingletonQuery(True)) + for s in lib.items(singletons_query): + if not s.mb_trackid: + log.info(u'Skipping singleton {0}: has no mb_trackid' + .format(s.title)) + continue - with lib.transaction(): - # Process matching singletons. - singletons_query = library.get_query(ui.decargs(args), False) - singletons_query.subqueries.append(library.SingletonQuery(True)) - for s in lib.items(singletons_query): - if not s.mb_trackid: - log.info(u'Skipping singleton {0}: has no mb_trackid' - .format(s.title)) - continue - - s.old_data = dict(s.record) - candidates, _ = autotag.match.tag_item(s, search_id=s.mb_trackid) - match = candidates[0] + s.old_data = dict(s.record) + candidates, _ = autotag.match.tag_item(s, search_id=s.mb_trackid) + match = candidates[0] + with lib.transaction(): autotag.apply_item_metadata(s, match.info) _print_and_apply_changes(lib, s, move, pretend, write) - # Process matching albums. - for a in lib.albums(ui.decargs(args)): - if not a.mb_albumid: - log.info(u'Skipping album {0}: has no mb_albumid'.format(a.id)) - continue - items = list(a.items()) - for item in items: - item.old_data = dict(item.record) +def mbsync_albums(lib, query, move, pretend, write): + """Synchronize matching albums. + """ + # Process matching albums. + for a in lib.albums(query): + if not a.mb_albumid: + log.info(u'Skipping album {0}: has no mb_albumid'.format(a.id)) + continue - _, _, candidates, _ = \ - autotag.match.tag_album(items, search_id=a.mb_albumid) - match = candidates[0] # There should only be one match! + items = list(a.items()) + for item in items: + item.old_data = dict(item.record) + + _, _, candidates, _ = \ + autotag.match.tag_album(items, search_id=a.mb_albumid) + match = candidates[0] # There should only be one match! + + with lib.transaction(): autotag.apply_metadata(match.info, match.mapping) - for item in items: _print_and_apply_changes(lib, item, move, pretend, write) @@ -96,6 +98,18 @@ def mbsync_func(lib, opts, args): a.move() +def mbsync_func(lib, opts, args): + """Command handler for the mbsync function. + """ + move = opts.move + pretend = opts.pretend + write = opts.write + query = ui.decargs(args) + + mbsync_singletons(lib, query, move, pretend, write) + mbsync_albums(lib, query, move, pretend, write) + + class MBSyncPlugin(BeetsPlugin): def __init__(self): super(MBSyncPlugin, self).__init__() From 72263a1cf71dbcb54022ae6dae14a3debfe41f6e Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 3 Mar 2013 17:08:07 -0800 Subject: [PATCH 25/88] mbsync: use ID lookups instead of full match logic This change uses _album_for_id and _track_for_id instead of the full autotag.match.* functions. This should be faster (requiring fewer calls to the MusicBrainz API) while also being more predictable. It also won't, for example, use acoustic fingerprinting even if the chroma plugin is installed. Finally, this change catches the error case in which MBIDs are erroneous. This can happen, for example, if the user has some track MBIDs left over from before the NGS transition. --- beetsplug/mbsync.py | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/beetsplug/mbsync.py b/beetsplug/mbsync.py index 96dc79319..bbf653f93 100644 --- a/beetsplug/mbsync.py +++ b/beetsplug/mbsync.py @@ -18,6 +18,7 @@ import logging from beets.plugins import BeetsPlugin from beets import autotag, library, ui, util +from beets.autotag import hooks log = logging.getLogger('beets') @@ -54,14 +55,20 @@ def mbsync_singletons(lib, query, move, pretend, write): for s in lib.items(singletons_query): if not s.mb_trackid: log.info(u'Skipping singleton {0}: has no mb_trackid' - .format(s.title)) + .format(s.title)) continue s.old_data = dict(s.record) - candidates, _ = autotag.match.tag_item(s, search_id=s.mb_trackid) - match = candidates[0] + + # Get the MusicBrainz recording info. + track_info = hooks._track_for_id(s.mb_trackid) + if not track_info: + log.info(u'Recording ID not found: {0}'.format(s.mb_trackid)) + continue + + # Apply. with lib.transaction(): - autotag.apply_item_metadata(s, match.info) + autotag.apply_item_metadata(s, track_info) _print_and_apply_changes(lib, s, move, pretend, write) @@ -78,12 +85,24 @@ def mbsync_albums(lib, query, move, pretend, write): for item in items: item.old_data = dict(item.record) - _, _, candidates, _ = \ - autotag.match.tag_album(items, search_id=a.mb_albumid) - match = candidates[0] # There should only be one match! + # Get the MusicBrainz album information. + album_info = hooks._album_for_id(a.mb_albumid) + if not album_info: + log.info(u'Release ID not found: {0}'.format(a.mb_albumid)) + continue + # Construct a track mapping according to MBIDs. This should work + # for albums that have missing or extra tracks. + mapping = {} + for item in items: + for track_info in album_info.tracks: + if item.mb_trackid == track_info.track_id: + mapping[item] = track_info + break + + # Apply. with lib.transaction(): - autotag.apply_metadata(match.info, match.mapping) + autotag.apply_metadata(album_info, mapping) for item in items: _print_and_apply_changes(lib, item, move, pretend, write) From 5f68d037936da16cb2448c58583580bb9827a5e6 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 3 Mar 2013 17:13:54 -0800 Subject: [PATCH 26/88] mbsync: don't write tags if import.write is off This will avoid surprising users with import.write turned off. --- beetsplug/mbsync.py | 3 ++- docs/plugins/mbsync.rst | 10 ++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/beetsplug/mbsync.py b/beetsplug/mbsync.py index bbf653f93..3313c6f07 100644 --- a/beetsplug/mbsync.py +++ b/beetsplug/mbsync.py @@ -19,6 +19,7 @@ import logging from beets.plugins import BeetsPlugin from beets import autotag, library, ui, util from beets.autotag import hooks +from beets import config log = logging.getLogger('beets') @@ -142,7 +143,7 @@ class MBSyncPlugin(BeetsPlugin): default=True, dest='move', help="don't move files in library") cmd.parser.add_option('-W', '--nowrite', action='store_false', - default=True, dest='write', + default=config['import']['write'], dest='write', help="don't write updated metadata to files") cmd.func = mbsync_func return [cmd] diff --git a/docs/plugins/mbsync.rst b/docs/plugins/mbsync.rst index 764be9d86..4bb3da32f 100644 --- a/docs/plugins/mbsync.rst +++ b/docs/plugins/mbsync.rst @@ -27,7 +27,9 @@ The command has a few command-line options: * To preview the changes that would be made without applying them, use the ``-p`` (``--pretend``) flag. -* By default all the new metadata will be written to the files and the files - will be moved according to their new metadata. This behavior can be changed - with the ``-W`` (``--nowrite``) and ``-M`` (``--nomove``) command line - options. +* By default, files will be moved (renamed) according to their metadata if + they are inside your beets library directory. To disable this, use the + ``-M`` (``--nomove``) command-line option. +* If you have the `import.write` configuration option enabled, then this + plugin will write new metadata to files' tags. To disable this, use the + ``-W`` (``--nowrite``) option. From 18688008a440467cb55e5d77b96ec85d1a3fdc29 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 3 Mar 2013 17:19:05 -0800 Subject: [PATCH 27/88] mbsync: avoid spurious stores/moves As _print_and_apply_changes itself does for items, we now shortcut modifications (metadata and filesystem) for albums when no changes are required for a given album. This avoids effectively doing a "beet move" on an album even when nothing has changed. --- beetsplug/mbsync.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/beetsplug/mbsync.py b/beetsplug/mbsync.py index 3313c6f07..e8fdf6c7a 100644 --- a/beetsplug/mbsync.py +++ b/beetsplug/mbsync.py @@ -25,12 +25,15 @@ log = logging.getLogger('beets') def _print_and_apply_changes(lib, item, move, pretend, write): + """Apply changes to an Item and preview them in the console. Return + a boolean indicating whether any changes were made. + """ changes = {} for key in library.ITEM_KEYS_META: if item.dirty[key]: changes[key] = item.old_data[key], getattr(item, key) if not changes: - return + return False # Something changed. ui.print_obj(item, lib) @@ -47,6 +50,8 @@ def _print_and_apply_changes(lib, item, move, pretend, write): item.write() lib.store(item) + return True + def mbsync_singletons(lib, query, move, pretend, write): """Synchronize matching singleton items. @@ -104,8 +109,13 @@ def mbsync_albums(lib, query, move, pretend, write): # Apply. with lib.transaction(): autotag.apply_metadata(album_info, mapping) + changed = False for item in items: - _print_and_apply_changes(lib, item, move, pretend, write) + changed = changed or \ + _print_and_apply_changes(lib, item, move, pretend, write) + if not changed: + # No change to any item. + continue if not pretend: # Update album structure to reflect an item in it. From 1a7ec6dc7976ec13f226da7f7626fcc2c8829977 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 3 Mar 2013 17:46:16 -0800 Subject: [PATCH 28/88] mbsync: fix redundant album art movement Since we explicitly move album art later in the process, implicitly moving it with items can cause a double-move (and thus a "file not found" error). --- beetsplug/mbsync.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/mbsync.py b/beetsplug/mbsync.py index e8fdf6c7a..97ecee3ae 100644 --- a/beetsplug/mbsync.py +++ b/beetsplug/mbsync.py @@ -44,7 +44,7 @@ def _print_and_apply_changes(lib, item, move, pretend, write): if not pretend: # Move the item if it's in the library. if move and lib.directory in util.ancestry(item.path): - lib.move(item) + lib.move(item, with_album=False) if write: item.write() From 5a8880e86fb1c0f32de3de84e2439f9f2da0a7a6 Mon Sep 17 00:00:00 2001 From: Lucas Duailibe Date: Wed, 6 Mar 2013 19:59:08 -0300 Subject: [PATCH 29/88] Correcting typo in writing.rst Simple typo: "fishes" -> "finishes" --- docs/plugins/writing.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugins/writing.rst b/docs/plugins/writing.rst index aee4f93bc..6a18b8fbe 100644 --- a/docs/plugins/writing.rst +++ b/docs/plugins/writing.rst @@ -114,7 +114,7 @@ currently available are: * *pluginload*: called after all the plugins have been loaded after the ``beet`` command starts -* *import*: called after a ``beet import`` command fishes (the ``lib`` keyword +* *import*: called after a ``beet import`` command finishes (the ``lib`` keyword argument is a Library object; ``paths`` is a list of paths (strings) that were imported) From 28522376be46f2d2e69a53852c6fa2fe86905414 Mon Sep 17 00:00:00 2001 From: Lucas Duailibe Date: Wed, 6 Mar 2013 21:41:40 -0300 Subject: [PATCH 30/88] Adding "keep new files" option to convert plugin --- beetsplug/convert.py | 43 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 3ce3e34a4..3706c3617 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -22,6 +22,7 @@ from subprocess import Popen from beets.plugins import BeetsPlugin from beets import ui, util from beetsplug.embedart import _embed +from beets import library from beets import config log = logging.getLogger('beets') @@ -47,12 +48,18 @@ def encode(source, dest): log.info(u'Finished encoding {0}'.format(util.displayable_path(source))) -def convert_item(lib, dest_dir): +def convert_item(lib, dest_dir, keep_new): while True: item = yield - dest = os.path.join(dest_dir, lib.destination(item, fragment=True)) - dest = os.path.splitext(dest)[0] + '.mp3' + if keep_new: + dest_new = lib.destination(item) + dest_new = os.path.splitext(dest_new)[0] + '.mp3' + dest = os.path.join(dest_dir, lib.destination(item, + fragment=True)) + else: + dest = os.path.join(dest_dir, lib.destination(item, fragment=True)) + dest = os.path.splitext(dest)[0] + '.mp3' if os.path.exists(util.syspath(dest)): log.info(u'Skipping {0} (target file exists)'.format( @@ -71,9 +78,16 @@ def convert_item(lib, dest_dir): log.info(u'Copying {0}'.format(util.displayable_path(item.path))) util.copy(item.path, dest) else: - encode(item.path, dest) + if keep_new: + encode(item.path, dest_new) + log.info(u'Copying to destination {0}'. + format(util.displayable_path(dest))) + util.move(item.path, dest) + item.path = dest_new + else: + encode(item.path, dest) + item.path = dest - item.path = dest item.write() if config['convert']['embed']: @@ -83,14 +97,23 @@ def convert_item(lib, dest_dir): if artpath: _embed(artpath, [item]) + if keep_new: + item.read() + log.info(u'Updating new format {0}'.format(item.format)) + item.write() + lib.store(item) + def convert_func(lib, opts, args): dest = opts.dest if opts.dest is not None else \ - config['convert']['dest'].get() + config['convert']['dest'].get() if not dest: raise ui.UserError('no convert destination set') threads = opts.threads if opts.threads is not None else \ - config['convert']['threads'].get(int) + config['convert']['threads'].get(int) + + keep_new = opts.keep_new if opts.keep_new is not None \ + else config['convert']['keep_new'].get() ui.commands.list_items(lib, ui.decargs(args), opts.album, None) @@ -101,7 +124,7 @@ def convert_func(lib, opts, args): items = (i for a in lib.albums(ui.decargs(args)) for i in a.items()) else: items = lib.items(ui.decargs(args)) - convert = [convert_item(lib, dest) for i in range(threads)] + convert = [convert_item(lib, dest, keep_new) for i in range(threads)] pipe = util.pipeline.Pipeline([items, convert]) pipe.run_parallel() @@ -116,6 +139,7 @@ class ConvertPlugin(BeetsPlugin): u'opts': u'-aq 2', u'max_bitrate': 500, u'embed': True, + u'keep_new': False }) def commands(self): @@ -125,6 +149,9 @@ class ConvertPlugin(BeetsPlugin): cmd.parser.add_option('-t', '--threads', action='store', type='int', help='change the number of threads, \ defaults to maximum availble processors ') + cmd.parser.add_option('-k', '--keep-new', action='store_true', + dest='keep_new', help='keep only the converted \ + and move the old files') cmd.parser.add_option('-d', '--dest', action='store', help='set the destination directory') cmd.func = convert_func From a338b95bb78a7d53e7a5cc3b29b051d43d664412 Mon Sep 17 00:00:00 2001 From: Lucas Duailibe Date: Wed, 6 Mar 2013 21:44:26 -0300 Subject: [PATCH 31/88] Changing behavior in "keep new files" --- beetsplug/convert.py | 56 +++++++++++++++++++++++----------------- docs/plugins/convert.rst | 4 ++- 2 files changed, 36 insertions(+), 24 deletions(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 3706c3617..11c23223f 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -30,6 +30,26 @@ DEVNULL = open(os.devnull, 'wb') _fs_lock = threading.Lock() +def _dest_out(lib, dest_dir, item, keep_new): + """Path to the files outside the directory""" + + if keep_new: + return os.path.join(dest_dir, lib.destination(item, fragment=True)) + + dest = os.path.join(dest_dir, lib.destination(item, fragment=True)) + return os.path.splitext(dest)[0] + '.mp3' + + +def _dest_converted(lib, dest_dir, item, keep_new): + """Path to the newly converted files""" + + if keep_new: + dest = lib.destination(item) + return os.path.splitext(dest)[0] + '.mp3' + + return _dest_out(lib, dest_dir, item, keep_new) + + def encode(source, dest): log.info(u'Started encoding {0}'.format(util.displayable_path(source))) @@ -52,16 +72,10 @@ def convert_item(lib, dest_dir, keep_new): while True: item = yield - if keep_new: - dest_new = lib.destination(item) - dest_new = os.path.splitext(dest_new)[0] + '.mp3' - dest = os.path.join(dest_dir, lib.destination(item, - fragment=True)) - else: - dest = os.path.join(dest_dir, lib.destination(item, fragment=True)) - dest = os.path.splitext(dest)[0] + '.mp3' + dest_converted = _dest_converted(lib, dest_dir, item, keep_new) + dest_out = _dest_out(lib, dest_dir, item, keep_new) - if os.path.exists(util.syspath(dest)): + if os.path.exists(util.syspath(dest_out)): log.info(u'Skipping {0} (target file exists)'.format( util.displayable_path(item.path) )) @@ -71,23 +85,21 @@ def convert_item(lib, dest_dir, keep_new): # time. (The existence check is not atomic with the directory # creation inside this function.) with _fs_lock: - util.mkdirall(dest) + util.mkdirall(dest_out) maxbr = config['convert']['max_bitrate'].get(int) if item.format == 'MP3' and item.bitrate < 1000 * maxbr: log.info(u'Copying {0}'.format(util.displayable_path(item.path))) - util.copy(item.path, dest) + util.copy(item.path, dest_out) else: - if keep_new: - encode(item.path, dest_new) - log.info(u'Copying to destination {0}'. - format(util.displayable_path(dest))) - util.move(item.path, dest) - item.path = dest_new - else: - encode(item.path, dest) - item.path = dest + encode(item.path, dest_converted) + if keep_new: + log.info(u'Moving to destination {0}'. + format(util.displayable_path(dest_out))) + util.move(item.path, dest_out) + + item.path = dest_converted item.write() if config['convert']['embed']: @@ -112,8 +124,7 @@ def convert_func(lib, opts, args): threads = opts.threads if opts.threads is not None else \ config['convert']['threads'].get(int) - keep_new = opts.keep_new if opts.keep_new is not None \ - else config['convert']['keep_new'].get() + keep_new = opts.keep_new ui.commands.list_items(lib, ui.decargs(args), opts.album, None) @@ -139,7 +150,6 @@ class ConvertPlugin(BeetsPlugin): u'opts': u'-aq 2', u'max_bitrate': 500, u'embed': True, - u'keep_new': False }) def commands(self): diff --git a/docs/plugins/convert.rst b/docs/plugins/convert.rst index 1295b6a04..fd45265a6 100644 --- a/docs/plugins/convert.rst +++ b/docs/plugins/convert.rst @@ -26,7 +26,9 @@ Usage To convert a part of your collection, run ``beet convert QUERY``. This will display all items matching ``QUERY`` and ask you for confirmation before starting the conversion. The ``-a`` (or ``--album``) option causes the command -to match albums instead of tracks. +to match albums instead of tracks. The ``-k`` (or ``--keep-new``) allows you to +keep the new, converted, files in your library and move the origin files to the +destination directory. The ``-t`` (``--threads``) and ``-d`` (``--dest``) options allow you to specify or overwrite the respective configuration options. From 87d71abc289bd7189fae9c7e80115feacb6a1991 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Wed, 6 Mar 2013 18:21:42 -0800 Subject: [PATCH 32/88] changelog/cleanup/fixes for #209 The major functional change here is how files move around when in keep_new mode. Now, files are first moved to the destination directory and then copied/transcoded back into the library. This avoids problems where naming conflicts could occur when transcoding from MP3 to MP3 (and thus not changing the filename). --- beetsplug/convert.py | 77 ++++++++++++++++++++-------------------- docs/changelog.rst | 3 ++ docs/plugins/convert.rst | 10 ++++-- 3 files changed, 48 insertions(+), 42 deletions(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 11c23223f..40b4dc4c4 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -22,7 +22,6 @@ from subprocess import Popen from beets.plugins import BeetsPlugin from beets import ui, util from beetsplug.embedart import _embed -from beets import library from beets import config log = logging.getLogger('beets') @@ -30,25 +29,19 @@ DEVNULL = open(os.devnull, 'wb') _fs_lock = threading.Lock() -def _dest_out(lib, dest_dir, item, keep_new): - """Path to the files outside the directory""" - +def _destination(lib, dest_dir, item, keep_new): + """Return the path under `dest_dir` where the file should be placed + (possibly after conversion). + """ + dest = lib.destination(item, basedir=dest_dir) if keep_new: - return os.path.join(dest_dir, lib.destination(item, fragment=True)) - - dest = os.path.join(dest_dir, lib.destination(item, fragment=True)) - return os.path.splitext(dest)[0] + '.mp3' - - -def _dest_converted(lib, dest_dir, item, keep_new): - """Path to the newly converted files""" - - if keep_new: - dest = lib.destination(item) + # When we're keeping the converted file, no extension munging + # occurs. + return dest + else: + # Otherwise, replace the extension with .mp3. return os.path.splitext(dest)[0] + '.mp3' - return _dest_out(lib, dest_dir, item, keep_new) - def encode(source, dest): log.info(u'Started encoding {0}'.format(util.displayable_path(source))) @@ -71,11 +64,9 @@ def encode(source, dest): def convert_item(lib, dest_dir, keep_new): while True: item = yield + dest = _destination(lib, dest_dir, item, keep_new) - dest_converted = _dest_converted(lib, dest_dir, item, keep_new) - dest_out = _dest_out(lib, dest_dir, item, keep_new) - - if os.path.exists(util.syspath(dest_out)): + if os.path.exists(util.syspath(dest)): log.info(u'Skipping {0} (target file exists)'.format( util.displayable_path(item.path) )) @@ -85,21 +76,36 @@ def convert_item(lib, dest_dir, keep_new): # time. (The existence check is not atomic with the directory # creation inside this function.) with _fs_lock: - util.mkdirall(dest_out) + util.mkdirall(dest) + + # When keeping the new file in the library, we first move the + # current (pristine) file to the destination. We'll then copy it + # back to its old path or transcode it to a new path. + if keep_new: + log.info(u'Moving to {0}'. + format(util.displayable_path(dest))) + util.move(item.path, dest) maxbr = config['convert']['max_bitrate'].get(int) if item.format == 'MP3' and item.bitrate < 1000 * maxbr: + # No transcoding necessary. log.info(u'Copying {0}'.format(util.displayable_path(item.path))) - util.copy(item.path, dest_out) - else: - encode(item.path, dest_converted) - if keep_new: - log.info(u'Moving to destination {0}'. - format(util.displayable_path(dest_out))) - util.move(item.path, dest_out) + util.copy(dest, item.path) + else: + util.copy(item.path, dest) - item.path = dest_converted + else: + if keep_new: + item.path = os.path.splitext(item.path)[0] + '.mp3' + encode(dest, item.path) + lib.store(item) + else: + encode(item.path, dest) + + # Write tags from the database to the converted file. + if not keep_new: + item.path = dest item.write() if config['convert']['embed']: @@ -109,21 +115,14 @@ def convert_item(lib, dest_dir, keep_new): if artpath: _embed(artpath, [item]) - if keep_new: - item.read() - log.info(u'Updating new format {0}'.format(item.format)) - item.write() - lib.store(item) - def convert_func(lib, opts, args): dest = opts.dest if opts.dest is not None else \ - config['convert']['dest'].get() + config['convert']['dest'].get() if not dest: raise ui.UserError('no convert destination set') threads = opts.threads if opts.threads is not None else \ - config['convert']['threads'].get(int) - + config['convert']['threads'].get(int) keep_new = opts.keep_new ui.commands.list_items(lib, ui.decargs(args), opts.album, None) diff --git a/docs/changelog.rst b/docs/changelog.rst index 49ce4edfa..797c930da 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -22,6 +22,9 @@ Other stuff: track in MusicBrainz and updates your library to reflect it. This can help you easily correct errors that have been fixed in the MB database. Thanks to Jakob Schnitzer. +* :doc:`/plugins/convert`: A new ``--keep-new`` option lets you store + transcoded files in your library while backing up the originals (instead of + vice-versa). Thanks to Lucas Duailibe. * :doc:`/plugins/echonest_tempo`: API errors now issue a warning instead of exiting with an exception. We also avoid an error when track metadata contains newlines. diff --git a/docs/plugins/convert.rst b/docs/plugins/convert.rst index fd45265a6..d88fc40f6 100644 --- a/docs/plugins/convert.rst +++ b/docs/plugins/convert.rst @@ -26,13 +26,17 @@ Usage To convert a part of your collection, run ``beet convert QUERY``. This will display all items matching ``QUERY`` and ask you for confirmation before starting the conversion. The ``-a`` (or ``--album``) option causes the command -to match albums instead of tracks. The ``-k`` (or ``--keep-new``) allows you to -keep the new, converted, files in your library and move the origin files to the -destination directory. +to match albums instead of tracks. The ``-t`` (``--threads``) and ``-d`` (``--dest``) options allow you to specify or overwrite the respective configuration options. +By default, the command places converted files into the destination directory +and leaves your library pristine. To instead back up your original files into +the destination directory and keep converted files in your library, use the +``-k`` (or ``--keep-new``) option. + + Configuration ------------- From 69f26594104c347090eb6365fa18be1af9717d09 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Wed, 6 Mar 2013 23:11:08 -0800 Subject: [PATCH 33/88] convert: read new audio metadata (#208) --- beetsplug/convert.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 40b4dc4c4..1a5574460 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -99,7 +99,6 @@ def convert_item(lib, dest_dir, keep_new): if keep_new: item.path = os.path.splitext(item.path)[0] + '.mp3' encode(dest, item.path) - lib.store(item) else: encode(item.path, dest) @@ -108,6 +107,12 @@ def convert_item(lib, dest_dir, keep_new): item.path = dest item.write() + # If we're keeping the transcoded file, read it again (after + # writing) to get new bitrate, duration, etc. + if keep_new: + item.read() + lib.store(item) # Store new path and audio data. + if config['convert']['embed']: album = lib.get_album(item) if album: From 218f10a62d2f30607ae5eae16cea737a037b58e1 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 9 Mar 2013 11:33:55 -0800 Subject: [PATCH 34/88] echonest_tempo: catch socket.error --- beetsplug/echonest_tempo.py | 3 ++- docs/changelog.rst | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/beetsplug/echonest_tempo.py b/beetsplug/echonest_tempo.py index 8e0b119bb..d3a55d213 100644 --- a/beetsplug/echonest_tempo.py +++ b/beetsplug/echonest_tempo.py @@ -22,6 +22,7 @@ from beets import ui from beets import config import pyechonest.config import pyechonest.song +import socket # Global logger. log = logging.getLogger('beets') @@ -79,7 +80,7 @@ def get_tempo(artist, title): else: log.warn(u'echonest_tempo: {0}'.format(e.args[0][0])) return None - except pyechonest.util.EchoNestIOError as e: + except (pyechonest.util.EchoNestIOError, socket.error) as e: log.debug(u'echonest_tempo: IO error: {0}'.format(e)) time.sleep(RETRY_INTERVAL) else: diff --git a/docs/changelog.rst b/docs/changelog.rst index 797c930da..1cc67dd8f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -47,6 +47,8 @@ Other stuff: are missing all metadata. * :doc:`/plugins/mbcollection`: Show friendly, human-readable errors when MusicBrainz exceptions occur. +* :doc:`/plugins/echonest_tempo`: Catch socket errors that are not handled by + the Echo Nest library. 1.1b2 (February 16, 2013) ------------------------- From 7d9fd0a2cf870a65cc8a73daaf753340545b314d Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 9 Mar 2013 11:37:10 -0800 Subject: [PATCH 35/88] convert: fix unicode error in path construction I introduced a regression a few commits ago when I started using lib.destination with the basedir keyword argument as opposed to doing os.path.join manually. --- beetsplug/convert.py | 1 + 1 file changed, 1 insertion(+) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 1a5574460..e086572e3 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -126,6 +126,7 @@ def convert_func(lib, opts, args): config['convert']['dest'].get() if not dest: raise ui.UserError('no convert destination set') + dest = util.bytestring_path(dest) threads = opts.threads if opts.threads is not None else \ config['convert']['threads'].get(int) keep_new = opts.keep_new From e49ca34f3cfe895a484c982ae0023d9d4bbeeb6b Mon Sep 17 00:00:00 2001 From: Lucas Duailibe Date: Sat, 9 Mar 2013 19:09:43 -0300 Subject: [PATCH 36/88] partial commit --- beetsplug/convert.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 1a5574460..4c0ced6b1 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -17,6 +17,7 @@ import logging import os import threading +import pdb from subprocess import Popen from beets.plugins import BeetsPlugin @@ -121,6 +122,18 @@ def convert_item(lib, dest_dir, keep_new): _embed(artpath, [item]) +def convert_on_import(lib, item): + maxbr = config['convert']['max_bitrate'].get(int) + if item.format != 'MP3' or item.bitrate >= 1000 * maxbr: + # Transcoding necessary + dest = os.path.splitext(item.path)[0] + '.mp3' + encode(item.path, dest) + item.path = dest + item.write() + item.read() + lib.store(item) + + def convert_func(lib, opts, args): dest = opts.dest if opts.dest is not None else \ config['convert']['dest'].get() @@ -154,7 +167,9 @@ class ConvertPlugin(BeetsPlugin): u'opts': u'-aq 2', u'max_bitrate': 500, u'embed': True, + u'auto_convert': False }) + self.import_stages = [self.auto_convert] def commands(self): cmd = ui.Subcommand('convert', help='convert to external location') @@ -170,3 +185,12 @@ class ConvertPlugin(BeetsPlugin): help='set the destination directory') cmd.func = convert_func return [cmd] + + def auto_convert(self, config, task): + if self.config['auto_convert'].get(): + pdb.set_trace() + if not task.is_album: + convert_on_import(config.lib, task.item) + else: + for item in task.items: + convert_on_import(config.lib, item) From 27b1d6d7ccbcd68dacaf0884ced529ac756666dc Mon Sep 17 00:00:00 2001 From: Lucas Duailibe Date: Sat, 9 Mar 2013 19:36:34 -0300 Subject: [PATCH 37/88] clean up old files and remove pdb --- beetsplug/convert.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 4c0ced6b1..3ed4aa726 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -17,7 +17,6 @@ import logging import os import threading -import pdb from subprocess import Popen from beets.plugins import BeetsPlugin @@ -28,6 +27,7 @@ from beets import config log = logging.getLogger('beets') DEVNULL = open(os.devnull, 'wb') _fs_lock = threading.Lock() +_convert_tmp = [] def _destination(lib, dest_dir, item, keep_new): @@ -127,6 +127,7 @@ def convert_on_import(lib, item): if item.format != 'MP3' or item.bitrate >= 1000 * maxbr: # Transcoding necessary dest = os.path.splitext(item.path)[0] + '.mp3' + _convert_tmp.append(dest) encode(item.path, dest) item.path = dest item.write() @@ -188,9 +189,16 @@ class ConvertPlugin(BeetsPlugin): def auto_convert(self, config, task): if self.config['auto_convert'].get(): - pdb.set_trace() if not task.is_album: convert_on_import(config.lib, task.item) else: for item in task.items: convert_on_import(config.lib, item) + + +@ConvertPlugin.listen('import_task_files') +def _cleanup(task, session): + for path in task.old_paths: + if path in _convert_tmp and os.path.isfile(path): + util.remove(path) + _convert_tmp.remove(path) From a3d8105a5a6153b25d2de2c39adf4d39bdb559ee Mon Sep 17 00:00:00 2001 From: Lucas Duailibe Date: Sat, 9 Mar 2013 20:04:26 -0300 Subject: [PATCH 38/88] small fix --- beetsplug/convert.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 3ed4aa726..111debeeb 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -199,6 +199,7 @@ class ConvertPlugin(BeetsPlugin): @ConvertPlugin.listen('import_task_files') def _cleanup(task, session): for path in task.old_paths: - if path in _convert_tmp and os.path.isfile(path): - util.remove(path) + if path in _convert_tmp: + if os.path.isfile(path): + util.remove(path) _convert_tmp.remove(path) From c2c96d522f71d26f9b8f62e425f8ceef8eea43eb Mon Sep 17 00:00:00 2001 From: Lucas Duailibe Date: Sat, 9 Mar 2013 23:33:45 -0300 Subject: [PATCH 39/88] config name and docs update --- beetsplug/convert.py | 4 ++-- docs/plugins/convert.rst | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 111debeeb..e79341eeb 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -168,7 +168,7 @@ class ConvertPlugin(BeetsPlugin): u'opts': u'-aq 2', u'max_bitrate': 500, u'embed': True, - u'auto_convert': False + u'auto': False }) self.import_stages = [self.auto_convert] @@ -188,7 +188,7 @@ class ConvertPlugin(BeetsPlugin): return [cmd] def auto_convert(self, config, task): - if self.config['auto_convert'].get(): + if self.config['auto'].get(): if not task.is_album: convert_on_import(config.lib, task.item) else: diff --git a/docs/plugins/convert.rst b/docs/plugins/convert.rst index d88fc40f6..6ec7d5b5e 100644 --- a/docs/plugins/convert.rst +++ b/docs/plugins/convert.rst @@ -57,6 +57,8 @@ The plugin offers several configuration options, all of which live under the "-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. +* ``auto`` gives you the option to import transcoded versions of your files + automatically during the ``import`` command. * 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 da81c7e5967c6422d42a395319756af85a764863 Mon Sep 17 00:00:00 2001 From: Philippe Mongeau Date: Sat, 9 Mar 2013 21:03:15 -0500 Subject: [PATCH 40/88] add internal FuzzyQuery using ~ as prefix --- beets/library.py | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/beets/library.py b/beets/library.py index 390d4e1dd..f6f98d17b 100644 --- a/beets/library.py +++ b/beets/library.py @@ -17,6 +17,7 @@ import sqlite3 import os import re +import difflib import sys import logging import shlex @@ -190,6 +191,13 @@ def _regexp(expr, val): return False return res is not None +def _fuzzy(expr, val): + if expr is None: + return False + val = util.as_string(val) + queryMatcher = difflib.SequenceMatcher(None, expr, val) + return queryMatcher.quick_ratio() > 0.7 + # Path element formatting for templating. def format_for_path(value, key=None, pathmod=None): """Sanitize the value for inclusion in a path: replace separators @@ -514,6 +522,23 @@ class RegexpQuery(FieldQuery): value = util.as_string(getattr(item, self.field)) return self.regexp.search(value) is not None +class FuzzyQuery(FieldQuery): + """A query using fuzzy matching""" + def __init__(self, field, pattern): + super(FuzzyQuery, self).__init__(field, pattern) + self.queryMatcher = difflib.SequenceMatcher(b=pattern) + + def clause(self): + # clause = self.field + " FUZZY ?" + clause = "FUZZY(" + self.field + ", ?)" + subvals = [self.pattern] + return clause, subvals + + def match(self, item): + value = util.as_string(getattr(item, self.field)) + queryMatcher.set_seq1(item) + return queryMatcher.quick_ratio() > 0.7 + class BooleanQuery(MatchQuery): """Matches a boolean field. Pattern should either be a boolean or a string reflecting a boolean. @@ -573,6 +598,7 @@ class CollectionQuery(Query): r')?' r'((? Date: Sat, 9 Mar 2013 21:19:00 -0500 Subject: [PATCH 41/88] add AnyFuzzyQuery to match on any fields using fuzzy matching --- beets/library.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/beets/library.py b/beets/library.py index f6f98d17b..bd2514140 100644 --- a/beets/library.py +++ b/beets/library.py @@ -655,6 +655,8 @@ class CollectionQuery(Query): # Match any field. if is_regexp: subq = AnyRegexpQuery(pattern, default_fields) + elif is_fuzzy: + subq = AnyFuzzyQuery(pattern, default_fields) else: subq = AnySubstringQuery(pattern, default_fields) subqueries.append(subq) @@ -763,6 +765,32 @@ class AnyRegexpQuery(CollectionQuery): self.regexp.match(val) is not None: return True return False + +class AnyFuzzyQuery(CollectionQuery): + """A query that uses fuzzy matching in any of a list of metadata fields.""" + def __init__(self, pattern, fields=None): + self.sequenceMatcher = difflib.SequenceMatcher(b=pattern) + self.fields = fields or ITEM_KEYS_WRITABLE + + subqueries = [] + for field in self.fields: + subqueries.append(FuzzyQuery(field, pattern)) + super(AnyFuzzyQuery, self).__init__(subqueries) + + def clause(self): + return self.clause_with_joiner('or') + + def match(self, item): + for field in self.fields: + try: + val = getattr(item, field) + except KeyError: + continue + if isinstance(val, basestring): + self.sequenceMatcher.set_seq1(val) + return self.sequenceMatcher.quick_ratio() > 0.7 + return False + class MutableCollectionQuery(CollectionQuery): """A collection query whose subqueries may be modified after the From a5f1357a94d3ca31eecbcef5f585e1c89857e808 Mon Sep 17 00:00:00 2001 From: Philippe Mongeau Date: Sat, 9 Mar 2013 22:09:56 -0500 Subject: [PATCH 42/88] use fuzzy threshold from config file --- beets/config_default.yaml | 3 +++ beets/library.py | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/beets/config_default.yaml b/beets/config_default.yaml index 210d7f1db..0a10ef795 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -70,3 +70,6 @@ match: partial: medium tracklength: strong tracknumber: strong + +fuzzy: + threshold: 0.7 diff --git a/beets/library.py b/beets/library.py index bd2514140..f41d7f669 100644 --- a/beets/library.py +++ b/beets/library.py @@ -196,7 +196,7 @@ def _fuzzy(expr, val): return False val = util.as_string(val) queryMatcher = difflib.SequenceMatcher(None, expr, val) - return queryMatcher.quick_ratio() > 0.7 + return queryMatcher.quick_ratio() > beets.config['fuzzy']['threshold'].as_number() # Path element formatting for templating. def format_for_path(value, key=None, pathmod=None): @@ -537,7 +537,7 @@ class FuzzyQuery(FieldQuery): def match(self, item): value = util.as_string(getattr(item, self.field)) queryMatcher.set_seq1(item) - return queryMatcher.quick_ratio() > 0.7 + return queryMatcher.quick_ratio() > beets.config['fuzzy']['threshold'].as_number() class BooleanQuery(MatchQuery): """Matches a boolean field. Pattern should either be a boolean or a From 8736a0bb4c1c2c0f7b5ea066f0a78243aa4cb116 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 10 Mar 2013 13:12:56 -0700 Subject: [PATCH 43/88] convert auto: changelog and de-cloning (#212) --- beetsplug/convert.py | 28 ++++++++++++++++++---------- docs/changelog.rst | 2 ++ docs/plugins/convert.rst | 4 +++- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index b0bb15254..1a454388a 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -27,7 +27,7 @@ from beets import config log = logging.getLogger('beets') DEVNULL = open(os.devnull, 'wb') _fs_lock = threading.Lock() -_convert_tmp = [] +_temp_files = [] # Keep track of temporary transcoded files for deletion. def _destination(lib, dest_dir, item, keep_new): @@ -62,6 +62,14 @@ def encode(source, dest): log.info(u'Finished encoding {0}'.format(util.displayable_path(source))) +def should_transcode(item): + """Determine whether the item should be transcoded as part of + conversion (i.e., its bitrate is high or it has the wrong format). + """ + maxbr = config['convert']['max_bitrate'].get(int) + return item.format != 'MP3' or item.bitrate >= 1000 * maxbr + + def convert_item(lib, dest_dir, keep_new): while True: item = yield @@ -87,8 +95,7 @@ def convert_item(lib, dest_dir, keep_new): format(util.displayable_path(dest))) util.move(item.path, dest) - maxbr = config['convert']['max_bitrate'].get(int) - if item.format == 'MP3' and item.bitrate < 1000 * maxbr: + if not should_transcode(item): # No transcoding necessary. log.info(u'Copying {0}'.format(util.displayable_path(item.path))) if keep_new: @@ -123,15 +130,16 @@ def convert_item(lib, dest_dir, keep_new): def convert_on_import(lib, item): - maxbr = config['convert']['max_bitrate'].get(int) - if item.format != 'MP3' or item.bitrate >= 1000 * maxbr: - # Transcoding necessary + """Transcode a file automatically after it is imported into the + library. + """ + if should_transcode(item): dest = os.path.splitext(item.path)[0] + '.mp3' - _convert_tmp.append(dest) + _temp_files.append(dest) # Delete the transcode later. encode(item.path, dest) item.path = dest item.write() - item.read() + item.read() # Load new audio information data. lib.store(item) @@ -200,7 +208,7 @@ class ConvertPlugin(BeetsPlugin): @ConvertPlugin.listen('import_task_files') def _cleanup(task, session): for path in task.old_paths: - if path in _convert_tmp: + if path in _temp_files: if os.path.isfile(path): util.remove(path) - _convert_tmp.remove(path) + _temp_files.remove(path) diff --git a/docs/changelog.rst b/docs/changelog.rst index 1cc67dd8f..84d2fc49c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -25,6 +25,8 @@ Other stuff: * :doc:`/plugins/convert`: A new ``--keep-new`` option lets you store transcoded files in your library while backing up the originals (instead of vice-versa). Thanks to Lucas Duailibe. +* :doc:`/plugins/convert`: Also, a new ``auto`` config option will transcode + audio files automatically during import. Thanks again to Lucas Duailibe. * :doc:`/plugins/echonest_tempo`: API errors now issue a warning instead of exiting with an exception. We also avoid an error when track metadata contains newlines. diff --git a/docs/plugins/convert.rst b/docs/plugins/convert.rst index 6ec7d5b5e..7486f38dc 100644 --- a/docs/plugins/convert.rst +++ b/docs/plugins/convert.rst @@ -58,7 +58,9 @@ The plugin offers several configuration options, all of which live under the ".) If you want to specify a bitrate, use "-ab ". Refer to the `FFmpeg`_ documentation for more details. * ``auto`` gives you the option to import transcoded versions of your files - automatically during the ``import`` command. + automatically during the ``import`` command. With this option enabled, the + importer will transcode all non-MP3 files over the maximum bitrate before + adding them to your library. * 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 01a449ffa60aefd57fefb9f8c38cca3d9e717873 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 10 Mar 2013 13:22:05 -0700 Subject: [PATCH 44/88] convert: auto (#212) now transcodes to /tmp This avoids naming conflicts in the source directory. In particular, when encoding MP3 -> MP3, the previous scheme would overwrite the original file (and hang ffmpeg waiting for input). This should also work in situations where the source directory is read-only. --- beetsplug/convert.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 1a454388a..8105dbbc1 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -18,6 +18,7 @@ import logging import os import threading from subprocess import Popen +import tempfile from beets.plugins import BeetsPlugin from beets import ui, util @@ -48,8 +49,8 @@ def encode(source, dest): log.info(u'Started encoding {0}'.format(util.displayable_path(source))) opts = config['convert']['opts'].get(unicode).split(u' ') - encode = Popen([config['convert']['ffmpeg'].get(unicode), '-i', source] + - opts + [dest], + encode = Popen([config['convert']['ffmpeg'].get(unicode), '-i', + source, '-y'] + opts + [dest], close_fds=True, stderr=DEVNULL) encode.wait() if encode.returncode != 0: @@ -134,7 +135,8 @@ def convert_on_import(lib, item): library. """ if should_transcode(item): - dest = os.path.splitext(item.path)[0] + '.mp3' + fd, dest = tempfile.mkstemp('.mp3') + os.close(fd) _temp_files.append(dest) # Delete the transcode later. encode(item.path, dest) item.path = dest From f7ced33b8e3da09d30e91a9bac819dd38d09fc3a Mon Sep 17 00:00:00 2001 From: Philippe Mongeau Date: Sun, 10 Mar 2013 16:43:34 -0400 Subject: [PATCH 45/88] add support for extending the query syntax with plugins --- beets/library.py | 92 ++++++++++++++++++++++-------------------------- beets/plugins.py | 12 +++++++ 2 files changed, 55 insertions(+), 49 deletions(-) diff --git a/beets/library.py b/beets/library.py index f41d7f669..c8cde3a01 100644 --- a/beets/library.py +++ b/beets/library.py @@ -191,13 +191,6 @@ def _regexp(expr, val): return False return res is not None -def _fuzzy(expr, val): - if expr is None: - return False - val = util.as_string(val) - queryMatcher = difflib.SequenceMatcher(None, expr, val) - return queryMatcher.quick_ratio() > beets.config['fuzzy']['threshold'].as_number() - # Path element formatting for templating. def format_for_path(value, key=None, pathmod=None): """Sanitize the value for inclusion in a path: replace separators @@ -522,22 +515,15 @@ class RegexpQuery(FieldQuery): value = util.as_string(getattr(item, self.field)) return self.regexp.search(value) is not None -class FuzzyQuery(FieldQuery): - """A query using fuzzy matching""" + +class PluginQuery(FieldQuery): def __init__(self, field, pattern): - super(FuzzyQuery, self).__init__(field, pattern) - self.queryMatcher = difflib.SequenceMatcher(b=pattern) + super(PluginQuery, self).__init__(field, pattern) + self.name = None def clause(self): - # clause = self.field + " FUZZY ?" - clause = "FUZZY(" + self.field + ", ?)" - subvals = [self.pattern] - return clause, subvals - - def match(self, item): - value = util.as_string(getattr(item, self.field)) - queryMatcher.set_seq1(item) - return queryMatcher.quick_ratio() > beets.config['fuzzy']['threshold'].as_number() + clause = "{name}({field}, ?)".format(name=self.name, field=self.field) + return clause, [self.pattern] class BooleanQuery(MatchQuery): """Matches a boolean field. Pattern should either be a boolean or a @@ -597,8 +583,6 @@ class CollectionQuery(Query): r'(? 0.7 + for subq in self.subqueries: + if subq.match(self.pattern, val): + return True return False - - + class MutableCollectionQuery(CollectionQuery): """A collection query whose subqueries may be modified after the query is initialized. @@ -1181,9 +1172,12 @@ class Library(BaseLibrary): # Access SELECT results like dictionaries. conn.row_factory = sqlite3.Row # Add the REGEXP function to SQLite queries. - conn.create_function("FUZZY", 2, _fuzzy) - conn.create_function("REGEXP", 2, _fuzzy) - # conn.create_function("REGEXP", 2, _fuzzy) + conn.create_function("REGEXP", 2, _regexp) + + # Register plugin queries + for query in plugins.queries(): + q = query(None, None) + conn.create_function(q.name, 2, q.match) self._connections[thread_id] = conn return conn diff --git a/beets/plugins.py b/beets/plugins.py index fbc863227..bc53949fd 100755 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -93,6 +93,10 @@ class BeetsPlugin(object): """ return {} + def queries(self): + """Should return a list of beets.library.PluginQuery""" + return () + listeners = None @classmethod @@ -209,6 +213,14 @@ def commands(): out += plugin.commands() return out +def queries(): + """Returns a list of beet.library.PluginQuery objects from all loaded plugins. + """ + out = [] + for plugin in find_plugins(): + out += plugin.queries() + return out + def track_distance(item, info): """Gets the track distance calculated by all loaded plugins. Returns a (distance, distance_max) pair. From 7314bc05241ec27a590090530cb039c32693f23d Mon Sep 17 00:00:00 2001 From: Philippe Mongeau Date: Sun, 10 Mar 2013 16:50:54 -0400 Subject: [PATCH 46/88] make fuzzy use PluginQuery instead of a subcommand --- beetsplug/fuzzy.py | 90 ++++++++++------------------------------------ 1 file changed, 19 insertions(+), 71 deletions(-) diff --git a/beetsplug/fuzzy.py b/beetsplug/fuzzy.py index 74693fd4b..a0ec33cd0 100644 --- a/beetsplug/fuzzy.py +++ b/beetsplug/fuzzy.py @@ -14,84 +14,32 @@ """Like beet list, but with fuzzy matching """ + from beets.plugins import BeetsPlugin +from beets.library import PluginQuery from beets.ui import Subcommand, decargs, print_obj -from beets.util.functemplate import Template from beets import config +from beets import util import difflib +class FuzzyQuery(PluginQuery): + def __init__(self, field, pattern): + super(FuzzyQuery, self).__init__(field, pattern) + # self.field = field + self.name = 'PLUGIN' + self.prefix = "~" -def fuzzy_score(queryMatcher, item): - queryMatcher.set_seq1(item) - return queryMatcher.quick_ratio() + def match(self, pattern, val): + if pattern is None: + return False + val = util.as_string(val) + queryMatcher = difflib.SequenceMatcher(None, pattern, val) + return queryMatcher.quick_ratio() > 0.7 -def is_match(queryMatcher, item, album=False, verbose=False, threshold=0.7): - if album: - values = [item.albumartist, item.album] - else: - values = [item.artist, item.album, item.title] - - s = max(fuzzy_score(queryMatcher, i.lower()) for i in values) - if verbose: - return (s >= threshold, s) - else: - return s >= threshold - - -def fuzzy_list(lib, opts, args): - query = decargs(args) - query = ' '.join(query).lower() - queryMatcher = difflib.SequenceMatcher(b=query) - - if opts.threshold is not None: - threshold = float(opts.threshold) - else: - threshold = config['fuzzy']['threshold'].as_number() - - if opts.path: - fmt = '$path' - else: - fmt = opts.format - template = Template(fmt) if fmt else None - - if opts.album: - objs = lib.albums() - else: - objs = lib.items() - - items = filter(lambda i: is_match(queryMatcher, i, album=opts.album, - threshold=threshold), objs) - - for item in items: - print_obj(item, lib, template) - if opts.verbose: - print(is_match(queryMatcher, item, - album=opts.album, verbose=True)[1]) - - -fuzzy_cmd = Subcommand('fuzzy', - help='list items using fuzzy matching') -fuzzy_cmd.parser.add_option('-a', '--album', action='store_true', - help='choose an album instead of track') -fuzzy_cmd.parser.add_option('-p', '--path', action='store_true', - help='print the path of the matched item') -fuzzy_cmd.parser.add_option('-f', '--format', action='store', - help='print with custom format', default=None) -fuzzy_cmd.parser.add_option('-v', '--verbose', action='store_true', - help='output scores for matches') -fuzzy_cmd.parser.add_option('-t', '--threshold', action='store', - help='return result with a fuzzy score above threshold. \ - (default is 0.7)', default=None) -fuzzy_cmd.func = fuzzy_list - - -class Fuzzy(BeetsPlugin): +class FuzzyPlugin(BeetsPlugin): def __init__(self): - super(Fuzzy, self).__init__() - self.config.add({ - 'threshold': 0.7, - }) + super(FuzzyPlugin, self).__init__(self) - def commands(self): - return [fuzzy_cmd] + def queries(self): + return [FuzzyQuery] From f6c3e4652c8f24a915c0ea6c52f54e6ac9b40564 Mon Sep 17 00:00:00 2001 From: Lucas Duailibe Date: Sun, 10 Mar 2013 18:12:16 -0300 Subject: [PATCH 47/88] fix unicode error --- beets/ui/commands.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 39702a85e..3f76a8328 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -643,9 +643,11 @@ def import_files(lib, paths, query): for path in paths: fullpath = syspath(normpath(path)) if not config['import']['singletons'] and not os.path.isdir(fullpath): - raise ui.UserError('not a directory: ' + path) + raise ui.UserError(u'not a directory: {0}'.format( + displayable_path(path))) elif config['import']['singletons'] and not os.path.exists(fullpath): - raise ui.UserError('no such file: ' + path) + raise ui.UserError(u'no such file: {0}'.format( + displayable_path(path))) # Check parameter consistency. if config['import']['quiet'] and config['import']['timid']: From 09156b03f06adf4397b8f5b1b349660088340412 Mon Sep 17 00:00:00 2001 From: Philippe Mongeau Date: Sun, 10 Mar 2013 17:37:03 -0400 Subject: [PATCH 48/88] fuzzy: use threshold value from config --- beets/config_default.yaml | 3 --- beetsplug/fuzzy.py | 6 +++++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/beets/config_default.yaml b/beets/config_default.yaml index 0a10ef795..210d7f1db 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -70,6 +70,3 @@ match: partial: medium tracklength: strong tracknumber: strong - -fuzzy: - threshold: 0.7 diff --git a/beetsplug/fuzzy.py b/beetsplug/fuzzy.py index a0ec33cd0..9ffb50fe4 100644 --- a/beetsplug/fuzzy.py +++ b/beetsplug/fuzzy.py @@ -22,6 +22,7 @@ from beets import config from beets import util import difflib + class FuzzyQuery(PluginQuery): def __init__(self, field, pattern): super(FuzzyQuery, self).__init__(field, pattern) @@ -34,12 +35,15 @@ class FuzzyQuery(PluginQuery): return False val = util.as_string(val) queryMatcher = difflib.SequenceMatcher(None, pattern, val) - return queryMatcher.quick_ratio() > 0.7 + return queryMatcher.quick_ratio() > config['fuzzy']['threshold'].as_number() class FuzzyPlugin(BeetsPlugin): def __init__(self): super(FuzzyPlugin, self).__init__(self) + self.config.add({ + 'threshold': 0.7, + }) def queries(self): return [FuzzyQuery] From 7d879289c1e46e3ef7a8fa1e66ed33d862abc451 Mon Sep 17 00:00:00 2001 From: Philippe Mongeau Date: Sun, 10 Mar 2013 18:00:28 -0400 Subject: [PATCH 49/88] fuzzy: add prefix config --- beetsplug/fuzzy.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/beetsplug/fuzzy.py b/beetsplug/fuzzy.py index 9ffb50fe4..363122e8d 100644 --- a/beetsplug/fuzzy.py +++ b/beetsplug/fuzzy.py @@ -18,8 +18,8 @@ from beets.plugins import BeetsPlugin from beets.library import PluginQuery from beets.ui import Subcommand, decargs, print_obj -from beets import config from beets import util +import beets import difflib @@ -28,14 +28,15 @@ class FuzzyQuery(PluginQuery): super(FuzzyQuery, self).__init__(field, pattern) # self.field = field self.name = 'PLUGIN' - self.prefix = "~" + self.prefix = beets.config['fuzzy']['prefix'].get() or '~' + self.threshold = beets.config['fuzzy']['threshold'].as_number() or 0.7 def match(self, pattern, val): if pattern is None: return False val = util.as_string(val) queryMatcher = difflib.SequenceMatcher(None, pattern, val) - return queryMatcher.quick_ratio() > config['fuzzy']['threshold'].as_number() + return queryMatcher.quick_ratio() > self.threshold class FuzzyPlugin(BeetsPlugin): @@ -43,6 +44,7 @@ class FuzzyPlugin(BeetsPlugin): super(FuzzyPlugin, self).__init__(self) self.config.add({ 'threshold': 0.7, + 'prefix': '~', }) def queries(self): From 33ff0e8fd25610c1b6f0005365b951440a440c8c Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 10 Mar 2013 15:20:29 -0700 Subject: [PATCH 50/88] changelog/thanks for #213 --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 84d2fc49c..bdd1eafdc 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -47,6 +47,8 @@ Other stuff: pathnames. * Fix a spurious warning from the Unidecode module when matching albums that are missing all metadata. +* Fix Unicode errors when a directory or file doesn't exist when invoking the + import command. Thanks to Lucas Duailibe. * :doc:`/plugins/mbcollection`: Show friendly, human-readable errors when MusicBrainz exceptions occur. * :doc:`/plugins/echonest_tempo`: Catch socket errors that are not handled by From 7bc0b3a5183ebb9eff578c681ab6f2cce0061cbd Mon Sep 17 00:00:00 2001 From: Philippe Mongeau Date: Sun, 10 Mar 2013 18:51:43 -0400 Subject: [PATCH 51/88] expclude prefix from query term --- beets/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beets/library.py b/beets/library.py index c8cde3a01..dd8e77e03 100644 --- a/beets/library.py +++ b/beets/library.py @@ -615,7 +615,7 @@ class CollectionQuery(Query): term = match.group(2) for p in prefixes: if term.startswith(p): - return (key, term, p) + return (key, term[len(p):], p) return (key, term, False) @classmethod From c9c57cbb29c8915e48498892953f726f3944439b Mon Sep 17 00:00:00 2001 From: Philippe Mongeau Date: Sun, 10 Mar 2013 19:06:27 -0400 Subject: [PATCH 52/88] fix param odrder for match function of PluginQuery --- beets/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beets/library.py b/beets/library.py index dd8e77e03..0ff690fcf 100644 --- a/beets/library.py +++ b/beets/library.py @@ -522,7 +522,7 @@ class PluginQuery(FieldQuery): self.name = None def clause(self): - clause = "{name}({field}, ?)".format(name=self.name, field=self.field) + clause = "{name}(?, {field})".format(name=self.name, field=self.field) return clause, [self.pattern] class BooleanQuery(MatchQuery): From 2a42c75cba5836a03506c604f1ca3a98d33197ab Mon Sep 17 00:00:00 2001 From: Philippe Mongeau Date: Sun, 10 Mar 2013 19:07:29 -0400 Subject: [PATCH 53/88] fuzzy: use smartcase for the pattern ignore case unless the pattern contains a capital letter --- beetsplug/fuzzy.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/beetsplug/fuzzy.py b/beetsplug/fuzzy.py index 363122e8d..9e6eb24c7 100644 --- a/beetsplug/fuzzy.py +++ b/beetsplug/fuzzy.py @@ -35,8 +35,11 @@ class FuzzyQuery(PluginQuery): if pattern is None: return False val = util.as_string(val) + # smartcase + if(pattern.islower()): + val = val.lower() queryMatcher = difflib.SequenceMatcher(None, pattern, val) - return queryMatcher.quick_ratio() > self.threshold + return queryMatcher.quick_ratio() >= self.threshold class FuzzyPlugin(BeetsPlugin): From ca8af62e9c9a289c8aec857000a5328851c65575 Mon Sep 17 00:00:00 2001 From: Philippe Mongeau Date: Sun, 10 Mar 2013 20:11:42 -0400 Subject: [PATCH 54/88] change queries() function to returns a dict of {prefix: PluginQuery} use the class __name__ as sqlite function name make RegexpQuery use the AnyPluginQuery --- beets/library.py | 36 +++++++++++++----------------------- beets/plugins.py | 10 +++++----- beetsplug/fuzzy.py | 13 ++++++++----- 3 files changed, 26 insertions(+), 33 deletions(-) diff --git a/beets/library.py b/beets/library.py index 0ff690fcf..2a33d3177 100644 --- a/beets/library.py +++ b/beets/library.py @@ -519,10 +519,9 @@ class RegexpQuery(FieldQuery): class PluginQuery(FieldQuery): def __init__(self, field, pattern): super(PluginQuery, self).__init__(field, pattern) - self.name = None def clause(self): - clause = "{name}(?, {field})".format(name=self.name, field=self.field) + clause = "{name}(?, {field})".format(name=self.__class__.__name__, field=self.field) return clause, [self.pattern] class BooleanQuery(MatchQuery): @@ -607,16 +606,17 @@ class CollectionQuery(Query): """ part = part.strip() match = cls._pq_regex.match(part) - prefixes = [':'] # default prefixes - for q in plugins.queries(): - prefixes.append(q(None, None).prefix) + + cls.prefixes = {':': RegexpQuery} + cls.prefixes.update(plugins.queries()) + if match: key = match.group(1) term = match.group(2) - for p in prefixes: + for p, q in cls.prefixes.items(): if term.startswith(p): - return (key, term[len(p):], p) - return (key, term, False) + return (key, term[len(p):], q) + return (key, term, None) @classmethod def from_strings(cls, query_parts, default_fields=None, @@ -631,13 +631,8 @@ class CollectionQuery(Query): res = cls._parse_query_part(part) if not res: continue - key, pattern, prefix = res - is_regexp = prefix == ':' - prefix_query = None - for q in plugins.queries(): - if q(None, None).prefix == prefix: - prefix_query = q + key, pattern, prefix_query = res # No key specified. if key is None: @@ -646,9 +641,7 @@ class CollectionQuery(Query): subqueries.append(PathQuery(pattern)) else: # Match any field. - if is_regexp: - subq = AnyRegexpQuery(pattern, default_fields) - elif prefix_query: + if prefix_query: subq = AnyPluginQuery(pattern, default_fields, cls=prefix_query) else: subq = AnySubstringQuery(pattern, default_fields) @@ -664,9 +657,7 @@ class CollectionQuery(Query): # Other (recognized) field. elif key.lower() in all_keys: - if is_regexp: - subqueries.append(RegexpQuery(key.lower(), pattern)) - elif prefix_query is not None: + if prefix_query is not None: subqueries.append(prefix_query(key.lower(), pattern)) else: subqueries.append(SubstringQuery(key.lower(), pattern)) @@ -1175,9 +1166,8 @@ class Library(BaseLibrary): conn.create_function("REGEXP", 2, _regexp) # Register plugin queries - for query in plugins.queries(): - q = query(None, None) - conn.create_function(q.name, 2, q.match) + for prefix, query in plugins.queries().items(): + conn.create_function(query.__name__, 2, query(None, None).match) self._connections[thread_id] = conn return conn diff --git a/beets/plugins.py b/beets/plugins.py index bc53949fd..9cb4a41ed 100755 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -94,8 +94,8 @@ class BeetsPlugin(object): return {} def queries(self): - """Should return a list of beets.library.PluginQuery""" - return () + """Should return a dict of {prefix : beets.library.PluginQuery}""" + return {} listeners = None @@ -214,11 +214,11 @@ def commands(): return out def queries(): - """Returns a list of beet.library.PluginQuery objects from all loaded plugins. + """Returns a dict of {prefix: beet.library.PluginQuery} objects from all loaded plugins. """ - out = [] + out = {} for plugin in find_plugins(): - out += plugin.queries() + out.update(plugin.queries()) return out def track_distance(item, info): diff --git a/beetsplug/fuzzy.py b/beetsplug/fuzzy.py index 9e6eb24c7..69c47a745 100644 --- a/beetsplug/fuzzy.py +++ b/beetsplug/fuzzy.py @@ -20,16 +20,17 @@ from beets.library import PluginQuery from beets.ui import Subcommand, decargs, print_obj from beets import util import beets +from beets.util import confit import difflib class FuzzyQuery(PluginQuery): def __init__(self, field, pattern): super(FuzzyQuery, self).__init__(field, pattern) - # self.field = field - self.name = 'PLUGIN' - self.prefix = beets.config['fuzzy']['prefix'].get() or '~' - self.threshold = beets.config['fuzzy']['threshold'].as_number() or 0.7 + try: + self.threshold = beets.config['fuzzy']['threshold'].as_number() + except confit.NotFoundError: + self.threshold = 0.7 def match(self, pattern, val): if pattern is None: @@ -51,4 +52,6 @@ class FuzzyPlugin(BeetsPlugin): }) def queries(self): - return [FuzzyQuery] + return { + self.config['prefix'].get(): FuzzyQuery, + } From 5d5b52629d7fee4c65bfef80474f1f53d2c80728 Mon Sep 17 00:00:00 2001 From: Philippe Mongeau Date: Sun, 10 Mar 2013 20:13:56 -0400 Subject: [PATCH 55/88] fix wrong indentation Oooops! --- beets/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beets/library.py b/beets/library.py index 2a33d3177..1bab392e6 100644 --- a/beets/library.py +++ b/beets/library.py @@ -1166,7 +1166,7 @@ class Library(BaseLibrary): conn.create_function("REGEXP", 2, _regexp) # Register plugin queries - for prefix, query in plugins.queries().items(): + for prefix, query in plugins.queries().items(): conn.create_function(query.__name__, 2, query(None, None).match) self._connections[thread_id] = conn From 685972bbc59cc32a2d1bebb678842f882733c7d5 Mon Sep 17 00:00:00 2001 From: Philippe Mongeau Date: Sun, 10 Mar 2013 20:26:45 -0400 Subject: [PATCH 56/88] update query tests fix escape colons in the query term --- beets/library.py | 2 +- test/test_query.py | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/beets/library.py b/beets/library.py index 1bab392e6..b17538f5a 100644 --- a/beets/library.py +++ b/beets/library.py @@ -612,7 +612,7 @@ class CollectionQuery(Query): if match: key = match.group(1) - term = match.group(2) + term = match.group(2).replace('\:', ':') for p, q in cls.prefixes.items(): if term.startswith(p): return (key, term[len(p):], q) diff --git a/test/test_query.py b/test/test_query.py index 8c7378518..3895ba623 100644 --- a/test/test_query.py +++ b/test/test_query.py @@ -27,37 +27,37 @@ some_item = _common.item() class QueryParseTest(unittest.TestCase): def test_one_basic_term(self): q = 'test' - r = (None, 'test', False) + r = (None, 'test', None) self.assertEqual(pqp(q), r) def test_one_keyed_term(self): q = 'test:val' - r = ('test', 'val', False) + r = ('test', 'val', None) self.assertEqual(pqp(q), r) def test_colon_at_end(self): q = 'test:' - r = (None, 'test:', False) + r = (None, 'test:', None) self.assertEqual(pqp(q), r) def test_one_basic_regexp(self): q = r':regexp' - r = (None, 'regexp', True) + r = (None, 'regexp', beets.library.RegexpQuery) self.assertEqual(pqp(q), r) def test_keyed_regexp(self): q = r'test::regexp' - r = ('test', 'regexp', True) + r = ('test', 'regexp', beets.library.RegexpQuery) self.assertEqual(pqp(q), r) def test_escaped_colon(self): q = r'test\:val' - r = (None, 'test:val', False) + r = (None, 'test:val', None) self.assertEqual(pqp(q), r) def test_escaped_colon_in_regexp(self): q = r':test\:regexp' - r = (None, 'test:regexp', True) + r = (None, 'test:regexp', beets.library.RegexpQuery) self.assertEqual(pqp(q), r) class AnySubstringQueryTest(unittest.TestCase): From 8b1511a8afa718954880103eb7c1e2944b9597b4 Mon Sep 17 00:00:00 2001 From: Philippe Mongeau Date: Sun, 10 Mar 2013 20:39:02 -0400 Subject: [PATCH 57/88] fix fuzzy config --- beetsplug/fuzzy.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/beetsplug/fuzzy.py b/beetsplug/fuzzy.py index 69c47a745..4362cbb9f 100644 --- a/beetsplug/fuzzy.py +++ b/beetsplug/fuzzy.py @@ -46,12 +46,11 @@ class FuzzyQuery(PluginQuery): class FuzzyPlugin(BeetsPlugin): def __init__(self): super(FuzzyPlugin, self).__init__(self) - self.config.add({ - 'threshold': 0.7, - 'prefix': '~', - }) def queries(self): - return { - self.config['prefix'].get(): FuzzyQuery, - } + try: + prefix = beets.config['fuzzy']['prefix'].get(basestring) + except confit.NotFoundError: + prefix = '~' + + return {prefix: FuzzyQuery} From 563c4be2ad519d8d958631cd0c63b94fc3119862 Mon Sep 17 00:00:00 2001 From: single-sandwiches Date: Mon, 11 Mar 2013 22:46:26 +1100 Subject: [PATCH 58/88] Update pathformat.rst Fixed link pointing to the MusicBrainz wiki in regards to the list of albumtype: type names --- docs/reference/pathformat.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/pathformat.rst b/docs/reference/pathformat.rst index a58dc976a..0e5c4ebdd 100644 --- a/docs/reference/pathformat.rst +++ b/docs/reference/pathformat.rst @@ -195,7 +195,7 @@ Ordinary metadata: * encoder .. _artist credit: http://wiki.musicbrainz.org/Artist_Credit -.. _list of type names: http://wiki.musicbrainz.org/XMLWebService#Release_Type_and_Status +.. _list of type names: http://wiki.musicbrainz.org/Development/XML_Web_Service/Version_2#Release_Type_and_Status Audio information: From 8a64686ff3d504bab3edcc1d558447ac0913b7db Mon Sep 17 00:00:00 2001 From: Lucas Duailibe Date: Mon, 11 Mar 2013 17:56:13 -0300 Subject: [PATCH 59/88] store fingerprints --- beetsplug/chroma.py | 19 +++++++++++++++++-- docs/plugins/chroma.rst | 5 +++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py index 2e2ce52b5..7722f2f9d 100644 --- a/beetsplug/chroma.py +++ b/beetsplug/chroma.py @@ -112,6 +112,11 @@ def _all_releases(items): yield release_id class AcoustidPlugin(plugins.BeetsPlugin): + def __init__(self): + super(AcoustidPlugin, self).__init__() + self.config.add({ + u'write': False + }) def track_distance(self, item, info): if item.path not in _matches: # Match failed. @@ -150,12 +155,15 @@ class AcoustidPlugin(plugins.BeetsPlugin): def commands(self): submit_cmd = ui.Subcommand('submit', help='submit Acoustid fingerprints') + submit_cmd.parser.add_option('-w', '--write', action='store_true', + help='store the calculated fingerprints') def submit_cmd_func(lib, opts, args): try: apikey = config['acoustid']['apikey'].get(unicode) except confit.NotFoundError: raise ui.UserError('no Acoustid user API key provided') - submit_items(apikey, lib.items(ui.decargs(args))) + submit_items(lib, apikey, lib.items(ui.decargs(args)), + write=opts.write) submit_cmd.func = submit_cmd_func return [submit_cmd] @@ -184,7 +192,7 @@ def apply_acoustid_metadata(task, session): # UI commands. -def submit_items(userkey, items, chunksize=64): +def submit_items(lib, userkey, items, chunksize=64, write=False): """Submit fingerprints for the items to the Acoustid server. """ data = [] # The running list of dictionaries to submit. @@ -212,6 +220,13 @@ def submit_items(userkey, items, chunksize=64): )) try: _, fp = acoustid.fingerprint_file(item.path) + item.acoustid_fingerprint = fp + if write: + log.info(u'{0}: storing fingerprint'.format( + util.displayable_path(item.path) + )) + item.write() + lib.store(item) except acoustid.FingerprintGenerationError as exc: log.info( 'fingerprint generation failed: {0}'.format(exc) diff --git a/docs/plugins/chroma.rst b/docs/plugins/chroma.rst index b24a4816a..1166ada5d 100644 --- a/docs/plugins/chroma.rst +++ b/docs/plugins/chroma.rst @@ -45,7 +45,7 @@ Next, you will need a mechanism for decoding audio files supported by the * On Linux, you can install `GStreamer for Python`_, `FFmpeg`_, or `MAD`_ and `pymad`_. How you install these will depend on your distribution. For example, on Ubuntu, run ``apt-get install python-gst0.10-dev``. On Arch Linux, you want - ``pacman -S gstreamer0.10-python``. + ``pacman -S gstreamer0.10-python``. * On Windows, try the Gstreamer "WinBuilds" from the `OSSBuild`_ project. @@ -94,6 +94,7 @@ value ``apikey`` in a section called ``acoustid`` like so:: Then, run ``beet submit``. (You can also provide a query to submit a subset of your library.) The command will use stored fingerprints if they're available; -otherwise it will fingerprint each file before submitting it. +otherwise it will fingerprint each file before submitting it. The ``-w``option +will store the fingerprints in the library. .. _get an API key: http://acoustid.org/api-key From 3390fd339a1eea51de5821d3e60ba5b9c567ae64 Mon Sep 17 00:00:00 2001 From: Lucas Duailibe Date: Mon, 11 Mar 2013 18:31:53 -0300 Subject: [PATCH 60/88] store fingerprints with the "fingerprint" command --- beetsplug/chroma.py | 86 ++++++++++++++++++++++++----------------- docs/plugins/chroma.rst | 7 +++- 2 files changed, 56 insertions(+), 37 deletions(-) diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py index 7722f2f9d..f2871254a 100644 --- a/beetsplug/chroma.py +++ b/beetsplug/chroma.py @@ -155,17 +155,21 @@ class AcoustidPlugin(plugins.BeetsPlugin): def commands(self): submit_cmd = ui.Subcommand('submit', help='submit Acoustid fingerprints') - submit_cmd.parser.add_option('-w', '--write', action='store_true', - help='store the calculated fingerprints') def submit_cmd_func(lib, opts, args): try: apikey = config['acoustid']['apikey'].get(unicode) except confit.NotFoundError: raise ui.UserError('no Acoustid user API key provided') - submit_items(lib, apikey, lib.items(ui.decargs(args)), - write=opts.write) + submit_items(apikey, lib.items(ui.decargs(args))) submit_cmd.func = submit_cmd_func - return [submit_cmd] + + fingerprint_cmd = ui.Subcommand('fingerprint', + help='fingerprints files with no fingerprint stored') + def fingerprint_cmd_func(lib, opts, args): + for item in lib.items(ui.decargs(args)): + fingerprint_item(item, lib=lib, write=True) + fingerprint_cmd.func = fingerprint_cmd_func + return [submit_cmd, fingerprint_cmd] # Hooks into import process. @@ -192,7 +196,7 @@ def apply_acoustid_metadata(task, session): # UI commands. -def submit_items(lib, userkey, items, chunksize=64, write=False): +def submit_items(userkey, items, chunksize=64): """Submit fingerprints for the items to the Acoustid server. """ data = [] # The running list of dictionaries to submit. @@ -203,35 +207,7 @@ def submit_items(lib, userkey, items, chunksize=64, write=False): del data[:] for item in items: - # Get a fingerprint and length for this track. - if not item.length: - log.info(u'{0}: no duration available'.format( - util.displayable_path(item.path) - )) - continue - elif item.acoustid_fingerprint: - log.info(u'{0}: using existing fingerprint'.format( - util.displayable_path(item.path) - )) - fp = item.acoustid_fingerprint - else: - log.info(u'{0}: fingerprinting'.format( - util.displayable_path(item.path) - )) - try: - _, fp = acoustid.fingerprint_file(item.path) - item.acoustid_fingerprint = fp - if write: - log.info(u'{0}: storing fingerprint'.format( - util.displayable_path(item.path) - )) - item.write() - lib.store(item) - except acoustid.FingerprintGenerationError as exc: - log.info( - 'fingerprint generation failed: {0}'.format(exc) - ) - continue + fp = fingerprint_item(item) # Construct a submission dictionary for this item. item_data = { @@ -261,3 +237,43 @@ def submit_items(lib, userkey, items, chunksize=64, write=False): # Submit remaining data in a final chunk. if data: submit_chunk() + + +def fingerprint_item(item, lib=None, write=False): + """Fingerprints files that don't already have prints stored + """ + # Get a fingerprint and length for this track. + if not item.length: + log.info(u'{0}: no duration available'.format( + util.displayable_path(item.path) + )) + return + elif item.acoustid_fingerprint: + if not write: + log.info(u'{0}: using existing fingerprint'.format( + util.displayable_path(item.path) + )) + return item.acoustid_fingerprint + log.info(u'{0}: skipping. fingerprint exsists'.format( + util.displayable_path(item.path) + )) + else: + log.info(u'{0}: fingerprinting'.format( + util.displayable_path(item.path) + )) + try: + _, fp = acoustid.fingerprint_file(item.path) + item.acoustid_fingerprint = fp + if write and lib is not None: + log.info(u'{0}: writing fingerprint'.format( + util.displayable_path(item.path) + )) + item.write() + lib.store(item) + return item.acoustid_fingerprint + except acoustid.FingerprintGenerationError as exc: + log.info( + 'fingerprint generation failed: {0}'.format(exc) + ) + return + diff --git a/docs/plugins/chroma.rst b/docs/plugins/chroma.rst index 1166ada5d..0677ca957 100644 --- a/docs/plugins/chroma.rst +++ b/docs/plugins/chroma.rst @@ -78,6 +78,10 @@ editing your :doc:`configuration file `. Put ``chroma`` on your ``plugins:`` line. With that, beets will use fingerprinting the next time you run ``beet import``. +You can also use ``beet fingerprint`` to fingerprint the tracks already imported +without fingerprints. (You can provide a query to fingerprint a subset of your +library). + .. _submitfp: Submitting Fingerprints @@ -94,7 +98,6 @@ value ``apikey`` in a section called ``acoustid`` like so:: Then, run ``beet submit``. (You can also provide a query to submit a subset of your library.) The command will use stored fingerprints if they're available; -otherwise it will fingerprint each file before submitting it. The ``-w``option -will store the fingerprints in the library. +otherwise it will fingerprint each file before submitting it. .. _get an API key: http://acoustid.org/api-key From a6ae5c4a4fb680fffbfa96ff7374f6bbbc579030 Mon Sep 17 00:00:00 2001 From: Lucas Duailibe Date: Mon, 11 Mar 2013 19:22:03 -0300 Subject: [PATCH 61/88] cleaning up --- beetsplug/chroma.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py index f2871254a..edb6a7282 100644 --- a/beetsplug/chroma.py +++ b/beetsplug/chroma.py @@ -112,11 +112,6 @@ def _all_releases(items): yield release_id class AcoustidPlugin(plugins.BeetsPlugin): - def __init__(self): - super(AcoustidPlugin, self).__init__() - self.config.add({ - u'write': False - }) def track_distance(self, item, info): if item.path not in _matches: # Match failed. From 704259b4595150fad26a34f62384b8b98b290e79 Mon Sep 17 00:00:00 2001 From: Lucas Duailibe Date: Mon, 11 Mar 2013 19:26:33 -0300 Subject: [PATCH 62/88] fix typo --- beetsplug/chroma.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py index edb6a7282..e8df67497 100644 --- a/beetsplug/chroma.py +++ b/beetsplug/chroma.py @@ -249,7 +249,7 @@ def fingerprint_item(item, lib=None, write=False): util.displayable_path(item.path) )) return item.acoustid_fingerprint - log.info(u'{0}: skipping. fingerprint exsists'.format( + log.info(u'{0}: skipping. fingerprint exists'.format( util.displayable_path(item.path) )) else: From aff3fb106d83062cf810c5d0ec2453ddd302bd2b Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Mon, 11 Mar 2013 19:19:48 -0700 Subject: [PATCH 63/88] chroma: fingerprint command's write from config This turns on metadata-writing based on the import.write config option, so those with this option turned off will be spared any surprises. (Affects #217 and #143.) --- beetsplug/chroma.py | 27 ++++++++++++++++----------- docs/plugins/chroma.rst | 8 +++++--- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py index e8df67497..d249893d8 100644 --- a/beetsplug/chroma.py +++ b/beetsplug/chroma.py @@ -159,11 +159,13 @@ class AcoustidPlugin(plugins.BeetsPlugin): submit_cmd.func = submit_cmd_func fingerprint_cmd = ui.Subcommand('fingerprint', - help='fingerprints files with no fingerprint stored') + help='generate fingerprints for items without them') def fingerprint_cmd_func(lib, opts, args): for item in lib.items(ui.decargs(args)): - fingerprint_item(item, lib=lib, write=True) + fingerprint_item(item, lib=lib, + write=config['import']['write'].get(bool)) fingerprint_cmd.func = fingerprint_cmd_func + return [submit_cmd, fingerprint_cmd] @@ -235,23 +237,27 @@ def submit_items(userkey, items, chunksize=64): def fingerprint_item(item, lib=None, write=False): - """Fingerprints files that don't already have prints stored + """Get the fingerprint for an Item. If the item already has a + fingerprint, it is not regenerated. If fingerprint generation fails, + return None. If `lib` is provided, then new fingerprints are saved + to the database. If `write` is set, then the new fingerprints are + also written to files' metadata. """ # Get a fingerprint and length for this track. if not item.length: log.info(u'{0}: no duration available'.format( util.displayable_path(item.path) )) - return elif item.acoustid_fingerprint: - if not write: + if write: + log.info(u'{0}: fingerprint exists, skipping'.format( + util.displayable_path(item.path) + )) + else: log.info(u'{0}: using existing fingerprint'.format( util.displayable_path(item.path) )) return item.acoustid_fingerprint - log.info(u'{0}: skipping. fingerprint exists'.format( - util.displayable_path(item.path) - )) else: log.info(u'{0}: fingerprinting'.format( util.displayable_path(item.path) @@ -259,16 +265,15 @@ def fingerprint_item(item, lib=None, write=False): try: _, fp = acoustid.fingerprint_file(item.path) item.acoustid_fingerprint = fp - if write and lib is not None: + if write: log.info(u'{0}: writing fingerprint'.format( util.displayable_path(item.path) )) item.write() + if lib: lib.store(item) return item.acoustid_fingerprint except acoustid.FingerprintGenerationError as exc: log.info( 'fingerprint generation failed: {0}'.format(exc) ) - return - diff --git a/docs/plugins/chroma.rst b/docs/plugins/chroma.rst index 0677ca957..9fea8cc96 100644 --- a/docs/plugins/chroma.rst +++ b/docs/plugins/chroma.rst @@ -78,9 +78,11 @@ editing your :doc:`configuration file `. Put ``chroma`` on your ``plugins:`` line. With that, beets will use fingerprinting the next time you run ``beet import``. -You can also use ``beet fingerprint`` to fingerprint the tracks already imported -without fingerprints. (You can provide a query to fingerprint a subset of your -library). +You can also use the ``beet fingerprint`` command to generate fingerprints for +items already in your library. (Provide a query to fingerprint a subset of your +library.) The generated fingerprints will be stored in the library database. +If you have the ``import.write`` config option enabled, they will also be +written to files' metadata. .. _submitfp: From fc0924f6662ad689dc0755716c0c339dcd1bc082 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Mon, 11 Mar 2013 19:24:18 -0700 Subject: [PATCH 64/88] changelog/thanks for #217 --- docs/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index bdd1eafdc..0edfddf7b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -27,6 +27,9 @@ Other stuff: vice-versa). Thanks to Lucas Duailibe. * :doc:`/plugins/convert`: Also, a new ``auto`` config option will transcode audio files automatically during import. Thanks again to Lucas Duailibe. +* :doc:`/plugins/chroma`: A new ``fingerprint`` command lets you generate and + store fingerprints for items that don't yet have them. One more round of + applause for Lucas Duailibe. * :doc:`/plugins/echonest_tempo`: API errors now issue a warning instead of exiting with an exception. We also avoid an error when track metadata contains newlines. From 3cb71a4f6d1e866ab6075bbfed566a783349a976 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Mon, 11 Mar 2013 19:35:26 -0700 Subject: [PATCH 65/88] python-musicbrainz-ngs 0.3 is out! --- docs/changelog.rst | 3 +-- setup.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 0edfddf7b..a5cc21cee 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -8,8 +8,7 @@ New configuration options: * :ref:`languages` controls the preferred languages when selecting an alias from MusicBrainz. This feature requires `python-musicbrainz-ngs`_ 0.3 or - later, which (at the time of this writing) is not yet released. Thanks to - Sam Doshi. + later. Thanks to Sam Doshi. * :ref:`detail` enables a mode where all tracks are listed in the importer UI, as opposed to only changed tracks. * The ``--flat`` option to the ``beet import`` command treats an entire diff --git a/setup.py b/setup.py index 9097d170c..295759b94 100755 --- a/setup.py +++ b/setup.py @@ -74,7 +74,7 @@ setup(name='beets', 'mutagen>=1.20', 'munkres', 'unidecode', - 'musicbrainzngs>=0.2', + 'musicbrainzngs>=0.3', 'pyyaml', ] + (['colorama'] if (sys.platform == 'win32') else []) From 7b7a4257aced75ee4e2fa830a3317d6dcbfd5395 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Mon, 11 Mar 2013 19:43:57 -0700 Subject: [PATCH 66/88] mbcollection: use library functions Since we now require python-musicbrainz-ngs 0.3 or later, we no longer have to hand-craft the API requests. --- beetsplug/mbcollection.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/beetsplug/mbcollection.py b/beetsplug/mbcollection.py index 9fb532978..4f9133fa9 100644 --- a/beetsplug/mbcollection.py +++ b/beetsplug/mbcollection.py @@ -19,15 +19,14 @@ from beets.ui import Subcommand from beets import ui from beets import config import musicbrainzngs -from musicbrainzngs import musicbrainz SUBMISSION_CHUNK_SIZE = 200 -def mb_request(*args, **kwargs): - """Send a MusicBrainz API request and process exceptions. +def mb_call(func, *args, **kwargs): + """Call a MusicBrainz API function and catch exceptions. """ try: - return musicbrainz._mb_request(*args, **kwargs) + return func(*args, **kwargs) except musicbrainzngs.AuthenticationError: raise ui.UserError('authentication with MusicBrainz failed') except musicbrainzngs.ResponseError as exc: @@ -41,17 +40,14 @@ def submit_albums(collection_id, release_ids): """ for i in range(0, len(release_ids), SUBMISSION_CHUNK_SIZE): chunk = release_ids[i:i+SUBMISSION_CHUNK_SIZE] - releaselist = ";".join(chunk) - mb_request( - "collection/%s/releases/%s" % (collection_id, releaselist), - 'PUT', True, True, body='foo' + mb_call( + musicbrainzngs.add_releases_to_collection, + collection_id, chunk ) - # A non-empty request body is required to avoid a 411 "Length - # Required" error from the MB server. def update_collection(lib, opts, args): # Get the collection to modify. - collections = mb_request('collection', 'GET', True, True) + collections = mb_call(musicbrainzngs.get_collections) if not collections['collection-list']: raise ui.UserError('no collections exist for user') collection_id = collections['collection-list'][0]['id'] From 1043bcc26178a1b5900d0a2755170b17ce871589 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Mon, 11 Mar 2013 19:45:31 -0700 Subject: [PATCH 67/88] remove py-mb-ngs version conditional Another code simplification due to requiring python-musicbrainz-ngs 0.3 or later. --- beets/autotag/mb.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/beets/autotag/mb.py b/beets/autotag/mb.py index 7b432d653..e67a78a09 100644 --- a/beets/autotag/mb.py +++ b/beets/autotag/mb.py @@ -45,13 +45,8 @@ class MusicBrainzAPIError(util.HumanReadableException): log = logging.getLogger('beets') RELEASE_INCLUDES = ['artists', 'media', 'recordings', 'release-groups', - 'labels', 'artist-credits'] -TRACK_INCLUDES = ['artists'] - -# Only versions >= 0.3 of python-musicbrainz-ngs support artist aliases. -if musicbrainzngs.musicbrainz._version >= '0.3': - RELEASE_INCLUDES.append('aliases') - TRACK_INCLUDES.append('aliases') + 'labels', 'artist-credits', 'aliases'] +TRACK_INCLUDES = ['artists', 'aliases'] def configure(): """Set up the python-musicbrainz-ngs module according to settings From 76310b876b469fd11b1f77636d40e959bf0cff23 Mon Sep 17 00:00:00 2001 From: Lucas Duailibe Date: Tue, 12 Mar 2013 10:34:51 -0300 Subject: [PATCH 68/88] collections broken link Broken link. The current way to see the collections is: ```http://musicbrainz.org/user/YOUR_USER/collections``` --- docs/plugins/mbcollection.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugins/mbcollection.rst b/docs/plugins/mbcollection.rst index f5a6df130..dc59ad88c 100644 --- a/docs/plugins/mbcollection.rst +++ b/docs/plugins/mbcollection.rst @@ -4,7 +4,7 @@ MusicBrainz Collection Plugin The ``mbcollection`` plugin lets you submit your catalog to MusicBrainz to maintain your `music collection`_ list there. -.. _music collection: http://musicbrainz.org/show/collection/ +.. _music collection: http://musicbrainz.org/doc/Collections To begin, just enable the ``mbcollection`` plugin (see :doc:`/plugins/index`). Then, add your MusicBrainz username and password to your From 2a7c22d74ab3372bb2d2a477b2d755e808f84d4b Mon Sep 17 00:00:00 2001 From: Lucas Duailibe Date: Tue, 12 Mar 2013 11:30:01 -0300 Subject: [PATCH 69/88] Collections broken link The collection link is now ```https://musicbrainz.org/user/USERNAME/collections``` so changed the link to the Collections documentation page --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index dc28d6d7e..ad2512c78 100644 --- a/README.rst +++ b/README.rst @@ -33,7 +33,7 @@ shockingly simple if you know a little Python. .. _plugins: http://beets.readthedocs.org/page/plugins/ .. _MPD: http://mpd.wikia.com/ -.. _MusicBrainz music collection: http://musicbrainz.org/show/collection/ +.. _MusicBrainz music collection: http://musicbrainz.org/doc/Collections/ .. _writing your own plugin: http://beets.readthedocs.org/page/plugins/#writing-plugins .. _HTML5 Audio: From ae40b975283ac86613b64fa828224537ec365e24 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 12 Mar 2013 20:30:57 -0700 Subject: [PATCH 70/88] chroma: catch acoustid exceptions during submit --- beetsplug/chroma.py | 5 ++++- docs/changelog.rst | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py index d249893d8..2dac5c89f 100644 --- a/beetsplug/chroma.py +++ b/beetsplug/chroma.py @@ -200,7 +200,10 @@ def submit_items(userkey, items, chunksize=64): def submit_chunk(): """Submit the current accumulated fingerprint data.""" log.info('submitting {0} fingerprints'.format(len(data))) - acoustid.submit(API_KEY, userkey, data) + try: + acoustid.submit(API_KEY, userkey, data) + except acoustid.AcoustidError as exc: + log.warn(u'acoustid submission error: {}'.format(exc)) del data[:] for item in items: diff --git a/docs/changelog.rst b/docs/changelog.rst index a5cc21cee..8572722d8 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -55,6 +55,8 @@ Other stuff: MusicBrainz exceptions occur. * :doc:`/plugins/echonest_tempo`: Catch socket errors that are not handled by the Echo Nest library. +* :doc:`/plugins/chroma`: Catch Acoustid Web service errors when submitting + fingerprints. 1.1b2 (February 16, 2013) ------------------------- From bc92b289e701d0f1bfcd8c96a070ddfb619811d3 Mon Sep 17 00:00:00 2001 From: Lucas Duailibe Date: Wed, 13 Mar 2013 01:05:25 -0300 Subject: [PATCH 71/88] importfeeds: absolute path option (#180) --- docs/plugins/importfeeds.rst | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/plugins/importfeeds.rst b/docs/plugins/importfeeds.rst index 09b831246..41c53cd84 100644 --- a/docs/plugins/importfeeds.rst +++ b/docs/plugins/importfeeds.rst @@ -15,11 +15,14 @@ relative to another folder than where the playlist is being written. If you're using importfeeds to generate a playlist for MPD, you should set this to the root of your music library. +The ``absolute_path`` configuration option can be set to use absolute paths +instead of relative paths. Some applications may need this to work properly. + Three different types of outputs coexist, specify the ones you want to use by -setting the ``formats`` parameter: +setting the ``formats`` parameter: - ``m3u``: catalog the imports in a centralized playlist. By default, the playlist is named ``imported.m3u``. To use a different file, just set the ``m3u_name`` parameter inside the ``importfeeds`` config section. -- ``m3u_multi``: create a new playlist for each import (uniquely named by appending the date and track/album name). +- ``m3u_multi``: create a new playlist for each import (uniquely named by appending the date and track/album name). - ``link``: create a symlink for each imported item. This is the recommended setting to propagate beets imports to your iTunes library: just drag and drop the ``dir`` folder on the iTunes dock icon. Here's an example configuration for this plugin:: From 9bae47f8abeccc8d9ee2200ddf9f0e31348ad8f4 Mon Sep 17 00:00:00 2001 From: Philippe Mongeau Date: Wed, 13 Mar 2013 00:39:35 -0400 Subject: [PATCH 72/88] update the docstrings and cleanup the new PluginQuery system --- beets/library.py | 37 +++++++++++++++++++++++-------------- beets/plugins.py | 11 ++++++----- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/beets/library.py b/beets/library.py index b17538f5a..b3b0eb760 100644 --- a/beets/library.py +++ b/beets/library.py @@ -517,6 +517,10 @@ class RegexpQuery(FieldQuery): class PluginQuery(FieldQuery): + """The base class to add queries using beets plugins. Plugins can add + special queries by defining a subclass of PluginQuery and overriding + the match method. + """ def __init__(self, field, pattern): super(PluginQuery, self).__init__(field, pattern) @@ -589,19 +593,19 @@ class CollectionQuery(Query): @classmethod def _parse_query_part(cls, part): """Takes a query in the form of a key/value pair separated by a - colon. An additional colon before the value indicates that the - value is a regular expression. Returns tuple (key, term, - is_regexp) where key is None if the search term has no key and - is_regexp indicates whether term is a regular expression or an - ordinary substring match. + colon. The value part is matched against a list of prefixes that can be + extended by plugins to add custom query types. For example, the colon + prefix denotes a regular exporession query. + + The function returns a tuple of(key, value, Query) For instance, - parse_query('stapler') == (None, 'stapler', false) - parse_query('color:red') == ('color', 'red', false) - parse_query(':^Quiet') == (None, '^Quiet', true) - parse_query('color::b..e') == ('color', 'b..e', true) + parse_query('stapler') == (None, 'stapler', None) + parse_query('color:red') == ('color', 'red', None) + parse_query(':^Quiet') == (None, '^Quiet', RegexpQuery) + parse_query('color::b..e') == ('color', 'b..e', RegexpQuery) - Colons may be 'escaped' with a backslash to disable the keying + Prefixes may be 'escaped' with a backslash to disable the keying behavior. """ part = part.strip() @@ -613,10 +617,11 @@ class CollectionQuery(Query): if match: key = match.group(1) term = match.group(2).replace('\:', ':') - for p, q in cls.prefixes.items(): - if term.startswith(p): - return (key, term[len(p):], q) - return (key, term, None) + # match the search term against the list of prefixes + for pre, query in cls.prefixes.items(): + if term.startswith(pre): + return (key, term[len(pre):], query) + return (key, term, None) # None means a normal query @classmethod def from_strings(cls, query_parts, default_fields=None, @@ -751,6 +756,10 @@ class AnyRegexpQuery(CollectionQuery): return False class AnyPluginQuery(CollectionQuery): + """A query that dispatch the matching function to the match method of + the cls provided to the contstructor using a list of metadata fields. + """ + def __init__(self, pattern, fields=None, cls=PluginQuery): subqueries = [] self.pattern = pattern diff --git a/beets/plugins.py b/beets/plugins.py index 9cb4a41ed..3749a464b 100755 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -54,6 +54,10 @@ class BeetsPlugin(object): commands that should be added to beets' CLI. """ return () + + def queries(self): + """Should return a dict of {prefix : beets.library.PluginQuery}""" + return {} def track_distance(self, item, info): """Should return a (distance, distance_max) pair to be added @@ -93,9 +97,6 @@ class BeetsPlugin(object): """ return {} - def queries(self): - """Should return a dict of {prefix : beets.library.PluginQuery}""" - return {} listeners = None @@ -214,8 +215,8 @@ def commands(): return out def queries(): - """Returns a dict of {prefix: beet.library.PluginQuery} objects from all loaded plugins. - """ + """Returns a dict of {prefix: beet.library.PluginQuery} objects from all + loaded plugins. """ out = {} for plugin in find_plugins(): out.update(plugin.queries()) From b9844ccf3a65392dd1468c10eb0242391c9f8655 Mon Sep 17 00:00:00 2001 From: Lucas Duailibe Date: Wed, 13 Mar 2013 01:05:25 -0300 Subject: [PATCH 73/88] importfeeds: absolute path option (#180) --- beetsplug/importfeeds.py | 14 +++++++++----- docs/plugins/importfeeds.rst | 7 +++++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/beetsplug/importfeeds.py b/beetsplug/importfeeds.py index a45e6013d..6a62130fb 100644 --- a/beetsplug/importfeeds.py +++ b/beetsplug/importfeeds.py @@ -34,10 +34,11 @@ class ImportFeedsPlugin(BeetsPlugin): 'm3u_name': u'imported.m3u', 'dir': None, 'relative_to': None, + 'absolute_path': False }) - + feeds_dir = self.config['dir'].get() - if feeds_dir: + if feeds_dir: feeds_dir = os.path.expanduser(bytestring_path(feeds_dir)) self.config['dir'] = feeds_dir if not os.path.exists(syspath(feeds_dir)): @@ -92,9 +93,12 @@ def _record_items(lib, basename, items): paths = [] for item in items: - paths.append(os.path.relpath( - item.path, relative_to - )) + if config['importfeeds']['absolute_path']: + paths.append(item.path) + else: + paths.append(os.path.relpath( + item.path, relative_to + )) if 'm3u' in formats: basename = bytestring_path( diff --git a/docs/plugins/importfeeds.rst b/docs/plugins/importfeeds.rst index 09b831246..41c53cd84 100644 --- a/docs/plugins/importfeeds.rst +++ b/docs/plugins/importfeeds.rst @@ -15,11 +15,14 @@ relative to another folder than where the playlist is being written. If you're using importfeeds to generate a playlist for MPD, you should set this to the root of your music library. +The ``absolute_path`` configuration option can be set to use absolute paths +instead of relative paths. Some applications may need this to work properly. + Three different types of outputs coexist, specify the ones you want to use by -setting the ``formats`` parameter: +setting the ``formats`` parameter: - ``m3u``: catalog the imports in a centralized playlist. By default, the playlist is named ``imported.m3u``. To use a different file, just set the ``m3u_name`` parameter inside the ``importfeeds`` config section. -- ``m3u_multi``: create a new playlist for each import (uniquely named by appending the date and track/album name). +- ``m3u_multi``: create a new playlist for each import (uniquely named by appending the date and track/album name). - ``link``: create a symlink for each imported item. This is the recommended setting to propagate beets imports to your iTunes library: just drag and drop the ``dir`` folder on the iTunes dock icon. Here's an example configuration for this plugin:: From 54d8adf6a634a0900ebe1920f8c581bbf8868490 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 12 Mar 2013 22:00:11 -0700 Subject: [PATCH 74/88] changelog for #180 --- beetsplug/importfeeds.py | 2 +- docs/changelog.rst | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/beetsplug/importfeeds.py b/beetsplug/importfeeds.py index 6a62130fb..f160bb9a7 100644 --- a/beetsplug/importfeeds.py +++ b/beetsplug/importfeeds.py @@ -34,7 +34,7 @@ class ImportFeedsPlugin(BeetsPlugin): 'm3u_name': u'imported.m3u', 'dir': None, 'relative_to': None, - 'absolute_path': False + 'absolute_path': False, }) feeds_dir = self.config['dir'].get() diff --git a/docs/changelog.rst b/docs/changelog.rst index 8572722d8..a7783f65e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -14,6 +14,8 @@ New configuration options: * The ``--flat`` option to the ``beet import`` command treats an entire directory tree of music files as a single album. This can help in situations where a multi-disc album is split across multiple directories. +* :doc:`/plugins/importfeeds`: An option was added to use absolute, rather + than relative, paths. Thanks to Lucas Duailibe. Other stuff: From a5367df66e1f6c7ad4574850992c8599365abd97 Mon Sep 17 00:00:00 2001 From: Philippe Mongeau Date: Wed, 13 Mar 2013 01:04:23 -0400 Subject: [PATCH 75/88] document how extend the query syntax in plugins using PluginQuery update the fuzzy documentation --- docs/plugins/fuzzy.rst | 28 ++++++++++++++-------------- docs/plugins/writing.rst | 31 +++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/docs/plugins/fuzzy.rst b/docs/plugins/fuzzy.rst index be659b386..604b0d998 100644 --- a/docs/plugins/fuzzy.rst +++ b/docs/plugins/fuzzy.rst @@ -1,25 +1,25 @@ Fuzzy Search Plugin =================== -The ``fuzzy`` plugin provides a command that search your library using -fuzzy pattern matching. This can be useful if you want to find a track with complicated characters in the title. +The ``fuzzy`` plugin provides a query prefix that search you library using fuzzy +pattern matching. This can be useful if you want to find a track with +complicated characters in the title. First, enable the plugin named ``fuzzy`` (see :doc:`/plugins/index`). -You'll then be able to use the ``beet fuzzy`` command:: +You'll then be able to use the ``~`` prefix to use fuzzy matching:: - $ beet fuzzy Vareoldur + $ beet ls '~Vareoldur' Sigur Rós - Valtari - Varðeldur -The command has several options that resemble those for the ``beet list`` -command (see :doc:`/reference/cli`). To choose an album instead of a single -track, use ``-a``; to print paths to items instead of metadata, use ``-p``; and -to use a custom format for printing, use ``-f FORMAT``. - -The ``-t NUMBER`` option lets you specify how precise the fuzzy match has to be -(default is 0.7). To make a fuzzier search, try ``beet fuzzy -t 0.5 Varoeldur``. -A value of ``1`` will show only perfect matches and a value of ``0`` will match everything. - -The default threshold can also be set in the config file:: +The plugin provides to config option to let you choose the prefix and the +threshold.:: fuzzy: threshold: 0.8 + prefix: '@' + +A threshold value of ``1`` will show only perfect matches and a value of ``0`` +will match everything. + +The default prefix ``~`` needs to be escaped or quoted in most shells. If this +bothers you, you can change the prefix in your config file. diff --git a/docs/plugins/writing.rst b/docs/plugins/writing.rst index 6a18b8fbe..76f297f53 100644 --- a/docs/plugins/writing.rst +++ b/docs/plugins/writing.rst @@ -323,3 +323,34 @@ to register it:: self.import_stages = [self.stage] def stage(self, config, task): print('Importing something!') + +Extend the Query Syntax +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Beets already support searching using regular expressions by prepending search +terms with the colon prefix. It is possible to add new prefix by extending the +``PluginQuery`` class. + +The plugin then need to declare its new queries by returning a ``dict`` of +``{prefix: PluginQuery}`` insied the ``queries`` method. + +The following example plugins declares a query using the ``@`` prefix. So the +plugin will be call if we issue a command like ``beet ls @something`` or +``beet ls artist:@something``.:: + + from beets.plugins import BeetsPlugin + from beets.Library import PluginQuery + + class ExampleQuery(PluginQuery): + def match(self, pattern, val): + return True # this will simply match everything + + class ExamplePlugin(BeetsPlugin): + def queries(): + # plugins need to declare theire queries by + # returning a dict of {prefix: PluginQuery} + # from the queries() function + return { + '@': ExampleQuery + } + From 280b43117387bae398b029b5750b67617c82abe4 Mon Sep 17 00:00:00 2001 From: Philippe Mongeau Date: Wed, 13 Mar 2013 18:29:11 -0400 Subject: [PATCH 76/88] changelog / doc for #214 --- docs/changelog.rst | 2 ++ docs/plugins/writing.rst | 8 +++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 1cc67dd8f..8ec3d2ddf 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -49,6 +49,8 @@ Other stuff: MusicBrainz exceptions occur. * :doc:`/plugins/echonest_tempo`: Catch socket errors that are not handled by the Echo Nest library. +* :ref:`extend-query`: Plugins can now extend the query syntax. Thanks to + Philippe Mongeau 1.1b2 (February 16, 2013) ------------------------- diff --git a/docs/plugins/writing.rst b/docs/plugins/writing.rst index 76f297f53..022ed1688 100644 --- a/docs/plugins/writing.rst +++ b/docs/plugins/writing.rst @@ -324,6 +324,8 @@ to register it:: def stage(self, config, task): print('Importing something!') +.. _extend-query: + Extend the Query Syntax ^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -332,11 +334,11 @@ terms with the colon prefix. It is possible to add new prefix by extending the ``PluginQuery`` class. The plugin then need to declare its new queries by returning a ``dict`` of -``{prefix: PluginQuery}`` insied the ``queries`` method. +``{prefix: PluginQuery}`` from the ``queries`` method. The following example plugins declares a query using the ``@`` prefix. So the -plugin will be call if we issue a command like ``beet ls @something`` or -``beet ls artist:@something``.:: +plugin will be called if we issue a command like ``beet ls @something`` or +``beet ls artist:@something``:: from beets.plugins import BeetsPlugin from beets.Library import PluginQuery From 40b49ac786f3f4cb567c10e738c24e9b24ce9bff Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Wed, 13 Mar 2013 21:59:03 -0700 Subject: [PATCH 77/88] some low-level tweaks to extensible queries (#214) --- beets/library.py | 85 +++++++++++++++------------------------- beets/plugins.py | 9 +++-- beetsplug/fuzzy.py | 7 ++-- docs/changelog.rst | 8 +++- docs/plugins/fuzzy.rst | 8 ++-- docs/plugins/writing.rst | 22 +++++------ 6 files changed, 61 insertions(+), 78 deletions(-) diff --git a/beets/library.py b/beets/library.py index b3b0eb760..8bef98d84 100644 --- a/beets/library.py +++ b/beets/library.py @@ -17,7 +17,6 @@ import sqlite3 import os import re -import difflib import sys import logging import shlex @@ -515,7 +514,6 @@ class RegexpQuery(FieldQuery): value = util.as_string(getattr(item, self.field)) return self.regexp.search(value) is not None - class PluginQuery(FieldQuery): """The base class to add queries using beets plugins. Plugins can add special queries by defining a subclass of PluginQuery and overriding @@ -525,9 +523,18 @@ class PluginQuery(FieldQuery): super(PluginQuery, self).__init__(field, pattern) def clause(self): - clause = "{name}(?, {field})".format(name=self.__class__.__name__, field=self.field) + # Invoke the registered SQLite function. + clause = "{name}(?, {field})".format(name=self.__class__.__name__, + field=self.field) return clause, [self.pattern] + @classmethod + def register(cls, conn): + """Register this query's matching function with the SQLite + connection. + """ + conn.create_function(cls.__name__, 2, cls(None, None).match) + class BooleanQuery(MatchQuery): """Matches a boolean field. Pattern should either be a boolean or a string reflecting a boolean. @@ -593,11 +600,14 @@ class CollectionQuery(Query): @classmethod def _parse_query_part(cls, part): """Takes a query in the form of a key/value pair separated by a - colon. The value part is matched against a list of prefixes that can be - extended by plugins to add custom query types. For example, the colon - prefix denotes a regular exporession query. + colon. The value part is matched against a list of prefixes that + can be extended by plugins to add custom query types. For + example, the colon prefix denotes a regular expression query. - The function returns a tuple of(key, value, Query) + The function returns a tuple of `(key, value, cls)`. `key` may + be None, indicating that any field may be matched. `cls` is + either a subclass of `PluginQuery` or `None` indicating a + "normal" query. For instance, parse_query('stapler') == (None, 'stapler', None) @@ -611,17 +621,17 @@ class CollectionQuery(Query): part = part.strip() match = cls._pq_regex.match(part) - cls.prefixes = {':': RegexpQuery} - cls.prefixes.update(plugins.queries()) + prefixes = {':': RegexpQuery} + prefixes.update(plugins.queries()) if match: key = match.group(1) term = match.group(2).replace('\:', ':') - # match the search term against the list of prefixes - for pre, query in cls.prefixes.items(): + # Match the search term against the list of prefixes. + for pre, query_class in prefixes.items(): if term.startswith(pre): - return (key, term[len(pre):], query) - return (key, term, None) # None means a normal query + return key, term[len(pre):], query_class + return key, term, None # None means a normal query. @classmethod def from_strings(cls, query_parts, default_fields=None, @@ -637,7 +647,7 @@ class CollectionQuery(Query): if not res: continue - key, pattern, prefix_query = res + key, pattern, query_class = res # No key specified. if key is None: @@ -646,8 +656,9 @@ class CollectionQuery(Query): subqueries.append(PathQuery(pattern)) else: # Match any field. - if prefix_query: - subq = AnyPluginQuery(pattern, default_fields, cls=prefix_query) + if query_class: + subq = AnyPluginQuery(pattern, default_fields, + cls=query_class) else: subq = AnySubstringQuery(pattern, default_fields) subqueries.append(subq) @@ -662,8 +673,8 @@ class CollectionQuery(Query): # Other (recognized) field. elif key.lower() in all_keys: - if prefix_query is not None: - subqueries.append(prefix_query(key.lower(), pattern)) + if query_class: + subqueries.append(query_class(key.lower(), pattern)) else: subqueries.append(SubstringQuery(key.lower(), pattern)) @@ -724,42 +735,10 @@ class AnySubstringQuery(CollectionQuery): return True return False -class AnyRegexpQuery(CollectionQuery): - """A query that matches a regexp in any of a list of metadata - fields. - """ - def __init__(self, pattern, fields=None): - """Create a query for regexp over the sequence of fields - given. If no fields are given, all available fields are - used. - """ - self.regexp = re.compile(pattern) - self.fields = fields or ITEM_KEYS_WRITABLE - - subqueries = [] - for field in self.fields: - subqueries.append(RegexpQuery(field, pattern)) - super(AnyRegexpQuery, self).__init__(subqueries) - - def clause(self): - return self.clause_with_joiner('or') - - def match(self, item): - for fld in self.fields: - try: - val = getattr(item, fld) - except KeyError: - continue - if isinstance(val, basestring) and \ - self.regexp.match(val) is not None: - return True - return False - class AnyPluginQuery(CollectionQuery): """A query that dispatch the matching function to the match method of the cls provided to the contstructor using a list of metadata fields. """ - def __init__(self, pattern, fields=None, cls=PluginQuery): subqueries = [] self.pattern = pattern @@ -1174,9 +1153,9 @@ class Library(BaseLibrary): # Add the REGEXP function to SQLite queries. conn.create_function("REGEXP", 2, _regexp) - # Register plugin queries - for prefix, query in plugins.queries().items(): - conn.create_function(query.__name__, 2, query(None, None).match) + # Register plugin queries. + for prefix, query_class in plugins.queries().items(): + query_class.register(conn) self._connections[thread_id] = conn return conn diff --git a/beets/plugins.py b/beets/plugins.py index 3749a464b..5a5a718f7 100755 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -56,7 +56,9 @@ class BeetsPlugin(object): return () def queries(self): - """Should return a dict of {prefix : beets.library.PluginQuery}""" + """Should return a dict mapping prefixes to PluginQuery + subclasses. + """ return {} def track_distance(self, item, info): @@ -215,8 +217,9 @@ def commands(): return out def queries(): - """Returns a dict of {prefix: beet.library.PluginQuery} objects from all - loaded plugins. """ + """Returns a dict mapping prefix strings to beet.library.PluginQuery + subclasses all loaded plugins. + """ out = {} for plugin in find_plugins(): out.update(plugin.queries()) diff --git a/beetsplug/fuzzy.py b/beetsplug/fuzzy.py index 4362cbb9f..2186499cf 100644 --- a/beetsplug/fuzzy.py +++ b/beetsplug/fuzzy.py @@ -12,12 +12,11 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -"""Like beet list, but with fuzzy matching +"""Provides a fuzzy matching query. """ from beets.plugins import BeetsPlugin from beets.library import PluginQuery -from beets.ui import Subcommand, decargs, print_obj from beets import util import beets from beets.util import confit @@ -28,7 +27,7 @@ class FuzzyQuery(PluginQuery): def __init__(self, field, pattern): super(FuzzyQuery, self).__init__(field, pattern) try: - self.threshold = beets.config['fuzzy']['threshold'].as_number() + self.threshold = beets.config['fuzzy']['threshold'].as_number() except confit.NotFoundError: self.threshold = 0.7 @@ -37,7 +36,7 @@ class FuzzyQuery(PluginQuery): return False val = util.as_string(val) # smartcase - if(pattern.islower()): + if pattern.islower(): val = val.lower() queryMatcher = difflib.SequenceMatcher(None, pattern, val) return queryMatcher.quick_ratio() >= self.threshold diff --git a/docs/changelog.rst b/docs/changelog.rst index ede4f82b1..b00876395 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -23,6 +23,12 @@ Other stuff: track in MusicBrainz and updates your library to reflect it. This can help you easily correct errors that have been fixed in the MB database. Thanks to Jakob Schnitzer. +* :doc:`/plugins/fuzzy`: The ``fuzzy`` command was removed and replaced with a + new query type. To perform fuzzy searches, use the ``~`` prefix with + :ref:`list-cmd` or other commands. Thanks to Philippe Mongeau. +* As part of the above, plugins can now extend the query syntax and new kinds + of matching capabilities to beets. See :ref:`extend-query`. Thanks again to + Philippe Mongeau. * :doc:`/plugins/convert`: A new ``--keep-new`` option lets you store transcoded files in your library while backing up the originals (instead of vice-versa). Thanks to Lucas Duailibe. @@ -59,8 +65,6 @@ Other stuff: the Echo Nest library. * :doc:`/plugins/chroma`: Catch Acoustid Web service errors when submitting fingerprints. -* :ref:`extend-query`: Plugins can now extend the query syntax. Thanks to - Philippe Mongeau 1.1b2 (February 16, 2013) ------------------------- diff --git a/docs/plugins/fuzzy.rst b/docs/plugins/fuzzy.rst index 604b0d998..3f4115168 100644 --- a/docs/plugins/fuzzy.rst +++ b/docs/plugins/fuzzy.rst @@ -1,8 +1,8 @@ Fuzzy Search Plugin =================== -The ``fuzzy`` plugin provides a query prefix that search you library using fuzzy -pattern matching. This can be useful if you want to find a track with +The ``fuzzy`` plugin provides a prefixed query that search you library using +fuzzy pattern matching. This can be useful if you want to find a track with complicated characters in the title. First, enable the plugin named ``fuzzy`` (see :doc:`/plugins/index`). @@ -11,14 +11,14 @@ You'll then be able to use the ``~`` prefix to use fuzzy matching:: $ beet ls '~Vareoldur' Sigur Rós - Valtari - Varðeldur -The plugin provides to config option to let you choose the prefix and the +The plugin provides config options that let you choose the prefix and the threshold.:: fuzzy: threshold: 0.8 prefix: '@' -A threshold value of ``1`` will show only perfect matches and a value of ``0`` +A threshold value of 1.0 will show only perfect matches and a value of 0.0 will match everything. The default prefix ``~`` needs to be escaped or quoted in most shells. If this diff --git a/docs/plugins/writing.rst b/docs/plugins/writing.rst index 022ed1688..0e8183763 100644 --- a/docs/plugins/writing.rst +++ b/docs/plugins/writing.rst @@ -327,32 +327,30 @@ to register it:: .. _extend-query: Extend the Query Syntax -^^^^^^^^^^^^^^^^^^^^^^^^^^ +^^^^^^^^^^^^^^^^^^^^^^^ -Beets already support searching using regular expressions by prepending search -terms with the colon prefix. It is possible to add new prefix by extending the -``PluginQuery`` class. +You can add new kinds of queries to beets' :doc:`query syntax +` indicated by a prefix. As an example, beets already +supports regular expression queries, which are indicated by a colon +prefix---plugins can do the same. -The plugin then need to declare its new queries by returning a ``dict`` of -``{prefix: PluginQuery}`` from the ``queries`` method. +To do so, define a subclass of the ``PluginQuery`` type from the +``beets.library`` module. Then, in the ``queries`` method of your plugin +class, return a dictionary mapping prefix strings to query classes. The following example plugins declares a query using the ``@`` prefix. So the plugin will be called if we issue a command like ``beet ls @something`` or ``beet ls artist:@something``:: from beets.plugins import BeetsPlugin - from beets.Library import PluginQuery + from beets.library import PluginQuery class ExampleQuery(PluginQuery): def match(self, pattern, val): - return True # this will simply match everything + return True # This will just match everything. class ExamplePlugin(BeetsPlugin): def queries(): - # plugins need to declare theire queries by - # returning a dict of {prefix: PluginQuery} - # from the queries() function return { '@': ExampleQuery } - From f005ec2de0123be40cb32131ac74af7943d257b7 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Wed, 13 Mar 2013 22:57:20 -0700 Subject: [PATCH 78/88] refactor: everything is like a plugin query (#214) The initial idea for this refactor was motivated by the need to make PluginQuery.match() have the same method signature as the match() methods on other queries. That is, it needed to take an *item*, not the pattern and value. (The pattern is supplied when the query is constructed.) So it made sense to move the value-to-pattern code to a class method. But then I realized that all the other FieldQuery subclasses needed to do essentially the same thing. So I eliminated PluginQuery altogether and refactored FieldQuery to subsume its functionality. I then changed all the other FieldQuery subclasses to conform to the same pattern. This has the side effect of allowing different kinds of queries (even non-field queries) down the road. --- beets/library.py | 189 +++++++++++++++++---------------------- beetsplug/fuzzy.py | 33 +++---- docs/plugins/writing.rst | 11 ++- test/test_query.py | 40 +++------ 4 files changed, 113 insertions(+), 160 deletions(-) diff --git a/beets/library.py b/beets/library.py index 8bef98d84..936da9ad3 100644 --- a/beets/library.py +++ b/beets/library.py @@ -469,58 +469,32 @@ class Query(object): class FieldQuery(Query): """An abstract query that searches in a specific field for a - pattern. + pattern. Subclasses must provide a `value_match` class method, which + determines whether a certain pattern string matches a certain value + string. They may then either override the `clause` method to use + native SQLite functionality or get registered to use a callback into + Python. """ def __init__(self, field, pattern): self.field = field self.pattern = pattern -class MatchQuery(FieldQuery): - """A query that looks for exact matches in an item field.""" - def clause(self): - pattern = self.pattern - if self.field == 'path' and isinstance(pattern, str): - pattern = buffer(pattern) - return self.field + " = ?", [pattern] + @classmethod + def value_match(cls, pattern, value): + """Determine whether the value matches the pattern. Both + arguments are strings. + """ + raise NotImplementedError() + + @classmethod + def _raw_value_match(cls, pattern, value): + """Determine whether the value matches the pattern. The value + may have any type. + """ + return cls.value_match(pattern, util.as_string(value)) def match(self, item): - return self.pattern == getattr(item, self.field) - -class SubstringQuery(FieldQuery): - """A query that matches a substring in a specific item field.""" - def clause(self): - search = '%' + (self.pattern.replace('\\','\\\\').replace('%','\\%') - .replace('_','\\_')) + '%' - clause = self.field + " like ? escape '\\'" - subvals = [search] - return clause, subvals - - def match(self, item): - value = util.as_string(getattr(item, self.field)) - return self.pattern.lower() in value.lower() - -class RegexpQuery(FieldQuery): - """A query that matches a regular expression in a specific item field.""" - def __init__(self, field, pattern): - super(RegexpQuery, self).__init__(field, pattern) - self.regexp = re.compile(pattern) - - def clause(self): - clause = self.field + " REGEXP ?" - subvals = [self.pattern] - return clause, subvals - - def match(self, item): - value = util.as_string(getattr(item, self.field)) - return self.regexp.search(value) is not None - -class PluginQuery(FieldQuery): - """The base class to add queries using beets plugins. Plugins can add - special queries by defining a subclass of PluginQuery and overriding - the match method. - """ - def __init__(self, field, pattern): - super(PluginQuery, self).__init__(field, pattern) + return self._raw_value_match(self.pattern, getattr(item, self.field)) def clause(self): # Invoke the registered SQLite function. @@ -531,9 +505,54 @@ class PluginQuery(FieldQuery): @classmethod def register(cls, conn): """Register this query's matching function with the SQLite - connection. + connection. This method should only be invoked when the query + type chooses not to override `clause`. """ - conn.create_function(cls.__name__, 2, cls(None, None).match) + conn.create_function(cls.__name__, 2, cls._raw_value_match) + +class MatchQuery(FieldQuery): + """A query that looks for exact matches in an item field.""" + def clause(self): + pattern = self.pattern + if self.field == 'path' and isinstance(pattern, str): + pattern = buffer(pattern) + return self.field + " = ?", [pattern] + + # We override the "raw" version here as a special case because we + # want to compare objects before conversion. + @classmethod + def _raw_value_match(cls, pattern, value): + return pattern == value + +class SubstringQuery(FieldQuery): + """A query that matches a substring in a specific item field.""" + def clause(self): + search = '%' + (self.pattern.replace('\\','\\\\').replace('%','\\%') + .replace('_','\\_')) + '%' + clause = self.field + " like ? escape '\\'" + subvals = [search] + return clause, subvals + + @classmethod + def value_match(cls, pattern, value): + return pattern.lower() in value.lower() + +class RegexpQuery(FieldQuery): + """A query that matches a regular expression in a specific item + field. + """ + def __init__(self, field, pattern): + super(RegexpQuery, self).__init__(field, pattern) + self.regexp = re.compile(pattern) + + def clause(self): + clause = self.field + " REGEXP ?" + subvals = [self.pattern] + return clause, subvals + + @classmethod + def value_match(cls, pattern, value): + return re.search(pattern, value) class BooleanQuery(MatchQuery): """Matches a boolean field. Pattern should either be a boolean or a @@ -605,9 +624,8 @@ class CollectionQuery(Query): example, the colon prefix denotes a regular expression query. The function returns a tuple of `(key, value, cls)`. `key` may - be None, indicating that any field may be matched. `cls` is - either a subclass of `PluginQuery` or `None` indicating a - "normal" query. + be None, indicating that any field may be matched. `cls` is a + subclass of `FieldQuery`. For instance, parse_query('stapler') == (None, 'stapler', None) @@ -631,7 +649,7 @@ class CollectionQuery(Query): for pre, query_class in prefixes.items(): if term.startswith(pre): return key, term[len(pre):], query_class - return key, term, None # None means a normal query. + return key, term, SubstringQuery # The default query type. @classmethod def from_strings(cls, query_parts, default_fields=None, @@ -656,11 +674,7 @@ class CollectionQuery(Query): subqueries.append(PathQuery(pattern)) else: # Match any field. - if query_class: - subq = AnyPluginQuery(pattern, default_fields, - cls=query_class) - else: - subq = AnySubstringQuery(pattern, default_fields) + subq = AnyFieldQuery(pattern, default_fields, query_class) subqueries.append(subq) # A boolean field. @@ -673,10 +687,7 @@ class CollectionQuery(Query): # Other (recognized) field. elif key.lower() in all_keys: - if query_class: - subqueries.append(query_class(key.lower(), pattern)) - else: - subqueries.append(SubstringQuery(key.lower(), pattern)) + subqueries.append(query_class(key.lower(), pattern)) # Singleton query (not a real field). elif key.lower() == 'singleton': @@ -704,62 +715,28 @@ class CollectionQuery(Query): return cls.from_strings(parts, default_fields=default_fields, all_keys=all_keys) -class AnySubstringQuery(CollectionQuery): - """A query that matches a substring in any of a list of metadata - fields. +class AnyFieldQuery(CollectionQuery): + """A query that matches if a given FieldQuery subclass matches in + any field. The individual field query class is provided to the + constructor. """ - def __init__(self, pattern, fields=None): - """Create a query for pattern over the sequence of fields - given. If no fields are given, all available fields are - used. - """ - self.pattern = pattern - self.fields = fields or ITEM_KEYS_WRITABLE - - subqueries = [] - for field in self.fields: - subqueries.append(SubstringQuery(field, pattern)) - super(AnySubstringQuery, self).__init__(subqueries) - - def clause(self): - return self.clause_with_joiner('or') - - def match(self, item): - for fld in self.fields: - try: - val = getattr(item, fld) - except KeyError: - continue - if isinstance(val, basestring) and \ - self.pattern.lower() in val.lower(): - return True - return False - -class AnyPluginQuery(CollectionQuery): - """A query that dispatch the matching function to the match method of - the cls provided to the contstructor using a list of metadata fields. - """ - def __init__(self, pattern, fields=None, cls=PluginQuery): - subqueries = [] + def __init__(self, pattern, fields, cls): self.pattern = pattern self.fields = fields + self.query_class = cls + + subqueries = [] for field in self.fields: subqueries.append(cls(field, pattern)) - super(AnyPluginQuery, self).__init__(subqueries) + super(AnyFieldQuery, self).__init__(subqueries) def clause(self): return self.clause_with_joiner('or') def match(self, item): - for field in self.fields: - try: - val = getattr(item, field) - except KeyError: - continue - if isinstance(val, basestring): - for subq in self.subqueries: - if subq.match(self.pattern, val): - return True + for subq in self.subqueries: + if subq.match(item): + return True return False class MutableCollectionQuery(CollectionQuery): diff --git a/beetsplug/fuzzy.py b/beetsplug/fuzzy.py index 2186499cf..8fb7724cc 100644 --- a/beetsplug/fuzzy.py +++ b/beetsplug/fuzzy.py @@ -16,40 +16,31 @@ """ from beets.plugins import BeetsPlugin -from beets.library import PluginQuery -from beets import util +from beets.library import FieldQuery import beets -from beets.util import confit import difflib -class FuzzyQuery(PluginQuery): - def __init__(self, field, pattern): - super(FuzzyQuery, self).__init__(field, pattern) - try: - self.threshold = beets.config['fuzzy']['threshold'].as_number() - except confit.NotFoundError: - self.threshold = 0.7 - - def match(self, pattern, val): - if pattern is None: - return False - val = util.as_string(val) +class FuzzyQuery(FieldQuery): + @classmethod + def value_match(self, pattern, val): # smartcase if pattern.islower(): val = val.lower() queryMatcher = difflib.SequenceMatcher(None, pattern, val) - return queryMatcher.quick_ratio() >= self.threshold + threshold = beets.config['fuzzy']['threshold'].as_number() + return queryMatcher.quick_ratio() >= threshold class FuzzyPlugin(BeetsPlugin): def __init__(self): + super(FuzzyPlugin, self).__init__() + self.config.add({ + 'prefix': '~', + 'threshold': 0.7, + }) super(FuzzyPlugin, self).__init__(self) def queries(self): - try: - prefix = beets.config['fuzzy']['prefix'].get(basestring) - except confit.NotFoundError: - prefix = '~' - + prefix = beets.config['fuzzy']['prefix'].get(basestring) return {prefix: FuzzyQuery} diff --git a/docs/plugins/writing.rst b/docs/plugins/writing.rst index 0e8183763..fbec02ed6 100644 --- a/docs/plugins/writing.rst +++ b/docs/plugins/writing.rst @@ -334,9 +334,11 @@ You can add new kinds of queries to beets' :doc:`query syntax supports regular expression queries, which are indicated by a colon prefix---plugins can do the same. -To do so, define a subclass of the ``PluginQuery`` type from the -``beets.library`` module. Then, in the ``queries`` method of your plugin -class, return a dictionary mapping prefix strings to query classes. +To do so, define a subclass of the ``FieldQuery`` type from the +``beets.library`` module. In this subclass, you should override the +``value_match`` class method. (Remember the ``@classmethod`` decorator!) Then, +in the ``queries`` method of your plugin class, return a dictionary mapping +prefix strings to query classes. The following example plugins declares a query using the ``@`` prefix. So the plugin will be called if we issue a command like ``beet ls @something`` or @@ -346,7 +348,8 @@ plugin will be called if we issue a command like ``beet ls @something`` or from beets.library import PluginQuery class ExampleQuery(PluginQuery): - def match(self, pattern, val): + @classmethod + def value_match(self, pattern, val): return True # This will just match everything. class ExamplePlugin(BeetsPlugin): diff --git a/test/test_query.py b/test/test_query.py index 3895ba623..5c29ab03f 100644 --- a/test/test_query.py +++ b/test/test_query.py @@ -27,17 +27,17 @@ some_item = _common.item() class QueryParseTest(unittest.TestCase): def test_one_basic_term(self): q = 'test' - r = (None, 'test', None) + r = (None, 'test', beets.library.SubstringQuery) self.assertEqual(pqp(q), r) def test_one_keyed_term(self): q = 'test:val' - r = ('test', 'val', None) + r = ('test', 'val', beets.library.SubstringQuery) self.assertEqual(pqp(q), r) def test_colon_at_end(self): q = 'test:' - r = (None, 'test:', None) + r = (None, 'test:', beets.library.SubstringQuery) self.assertEqual(pqp(q), r) def test_one_basic_regexp(self): @@ -52,7 +52,7 @@ class QueryParseTest(unittest.TestCase): def test_escaped_colon(self): q = r'test\:val' - r = (None, 'test:val', None) + r = (None, 'test:val', beets.library.SubstringQuery) self.assertEqual(pqp(q), r) def test_escaped_colon_in_regexp(self): @@ -60,42 +60,24 @@ class QueryParseTest(unittest.TestCase): r = (None, 'test:regexp', beets.library.RegexpQuery) self.assertEqual(pqp(q), r) -class AnySubstringQueryTest(unittest.TestCase): +class AnyFieldQueryTest(unittest.TestCase): def setUp(self): self.lib = beets.library.Library(':memory:') self.lib.add(some_item) def test_no_restriction(self): - q = beets.library.AnySubstringQuery('title') + q = beets.library.AnyFieldQuery('title', beets.library.ITEM_KEYS, + beets.library.SubstringQuery) self.assertEqual(self.lib.items(q).next().title, 'the title') def test_restriction_completeness(self): - q = beets.library.AnySubstringQuery('title', ['title']) + q = beets.library.AnyFieldQuery('title', ['title'], + beets.library.SubstringQuery) self.assertEqual(self.lib.items(q).next().title, 'the title') def test_restriction_soundness(self): - q = beets.library.AnySubstringQuery('title', ['artist']) - self.assertRaises(StopIteration, self.lib.items(q).next) - -class AnyRegexpQueryTest(unittest.TestCase): - def setUp(self): - self.lib = beets.library.Library(':memory:') - self.lib.add(some_item) - - def test_no_restriction(self): - q = beets.library.AnyRegexpQuery(r'^the ti') - self.assertEqual(self.lib.items(q).next().title, 'the title') - - def test_restriction_completeness(self): - q = beets.library.AnyRegexpQuery(r'^the ti', ['title']) - self.assertEqual(self.lib.items(q).next().title, 'the title') - - def test_restriction_soundness(self): - q = beets.library.AnyRegexpQuery(r'^the ti', ['artist']) - self.assertRaises(StopIteration, self.lib.items(q).next) - - def test_restriction_soundness_2(self): - q = beets.library.AnyRegexpQuery(r'the ti$', ['title']) + q = beets.library.AnyFieldQuery('title', ['artist'], + beets.library.SubstringQuery) self.assertRaises(StopIteration, self.lib.items(q).next) # Convenient asserts for matching items. From a4fb44ab1b42d939ce685bd7137617c23ce0e1c3 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Wed, 13 Mar 2013 23:03:21 -0700 Subject: [PATCH 79/88] refactor RegexpQuery to use new FieldQuery (#214) --- beets/library.py | 34 +++++++--------------------------- 1 file changed, 7 insertions(+), 27 deletions(-) diff --git a/beets/library.py b/beets/library.py index 936da9ad3..4525581a3 100644 --- a/beets/library.py +++ b/beets/library.py @@ -175,21 +175,6 @@ def _orelse(exp1, exp2): 'WHEN "" THEN {1} ' 'ELSE {0} END)').format(exp1, exp2) -# An SQLite function for regular expression matching. -def _regexp(expr, val): - """Return a boolean indicating whether the regular expression `expr` - matches `val`. - """ - if expr is None: - return False - val = util.as_string(val) - try: - res = re.search(expr, val) - except re.error: - # Invalid regular expression. - return False - return res is not None - # Path element formatting for templating. def format_for_path(value, key=None, pathmod=None): """Sanitize the value for inclusion in a path: replace separators @@ -541,18 +526,14 @@ class RegexpQuery(FieldQuery): """A query that matches a regular expression in a specific item field. """ - def __init__(self, field, pattern): - super(RegexpQuery, self).__init__(field, pattern) - self.regexp = re.compile(pattern) - - def clause(self): - clause = self.field + " REGEXP ?" - subvals = [self.pattern] - return clause, subvals - @classmethod def value_match(cls, pattern, value): - return re.search(pattern, value) + try: + res = re.search(pattern, value) + except re.error: + # Invalid regular expression. + return False + return res is not None class BooleanQuery(MatchQuery): """Matches a boolean field. Pattern should either be a boolean or a @@ -1127,10 +1108,9 @@ class Library(BaseLibrary): # Access SELECT results like dictionaries. conn.row_factory = sqlite3.Row - # Add the REGEXP function to SQLite queries. - conn.create_function("REGEXP", 2, _regexp) # Register plugin queries. + RegexpQuery.register(conn) for prefix, query_class in plugins.queries().items(): query_class.register(conn) From f474f3aed2a8d19592fb6cc95c461537b1fbc125 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 14 Mar 2013 10:00:30 -0700 Subject: [PATCH 80/88] split FieldQuery into base and registered versions --- beets/library.py | 15 ++++++++++----- beetsplug/fuzzy.py | 4 ++-- docs/plugins/writing.rst | 26 ++++++++++++++------------ 3 files changed, 26 insertions(+), 19 deletions(-) diff --git a/beets/library.py b/beets/library.py index 4525581a3..c8460955d 100644 --- a/beets/library.py +++ b/beets/library.py @@ -456,9 +456,8 @@ class FieldQuery(Query): """An abstract query that searches in a specific field for a pattern. Subclasses must provide a `value_match` class method, which determines whether a certain pattern string matches a certain value - string. They may then either override the `clause` method to use - native SQLite functionality or get registered to use a callback into - Python. + string. Subclasses also need to provide `clause` to implement the + same matching functionality in SQLite. """ def __init__(self, field, pattern): self.field = field @@ -481,6 +480,11 @@ class FieldQuery(Query): def match(self, item): return self._raw_value_match(self.pattern, getattr(item, self.field)) +class RegisteredFieldQuery(FieldQuery): + """A FieldQuery that uses a registered SQLite callback function. + Before it can be used to execute queries, the `register` method must + be called. + """ def clause(self): # Invoke the registered SQLite function. clause = "{name}(?, {field})".format(name=self.__class__.__name__, @@ -522,7 +526,7 @@ class SubstringQuery(FieldQuery): def value_match(cls, pattern, value): return pattern.lower() in value.lower() -class RegexpQuery(FieldQuery): +class RegexpQuery(RegisteredFieldQuery): """A query that matches a regular expression in a specific item field. """ @@ -1112,7 +1116,8 @@ class Library(BaseLibrary): # Register plugin queries. RegexpQuery.register(conn) for prefix, query_class in plugins.queries().items(): - query_class.register(conn) + if issubclass(query_class, RegisteredFieldQuery): + query_class.register(conn) self._connections[thread_id] = conn return conn diff --git a/beetsplug/fuzzy.py b/beetsplug/fuzzy.py index 8fb7724cc..14ae54ffc 100644 --- a/beetsplug/fuzzy.py +++ b/beetsplug/fuzzy.py @@ -16,12 +16,12 @@ """ from beets.plugins import BeetsPlugin -from beets.library import FieldQuery +from beets.library import RegisteredFieldQuery import beets import difflib -class FuzzyQuery(FieldQuery): +class FuzzyQuery(RegisteredFieldQuery): @classmethod def value_match(self, pattern, val): # smartcase diff --git a/docs/plugins/writing.rst b/docs/plugins/writing.rst index fbec02ed6..d92ea8374 100644 --- a/docs/plugins/writing.rst +++ b/docs/plugins/writing.rst @@ -334,26 +334,28 @@ You can add new kinds of queries to beets' :doc:`query syntax supports regular expression queries, which are indicated by a colon prefix---plugins can do the same. -To do so, define a subclass of the ``FieldQuery`` type from the -``beets.library`` module. In this subclass, you should override the -``value_match`` class method. (Remember the ``@classmethod`` decorator!) Then, -in the ``queries`` method of your plugin class, return a dictionary mapping -prefix strings to query classes. +To do so, define a subclass of the ``Query`` type from the ``beets.library`` +module. Then, in the ``queries`` method of your plugin class, return a +dictionary mapping prefix strings to query classes. -The following example plugins declares a query using the ``@`` prefix. So the -plugin will be called if we issue a command like ``beet ls @something`` or -``beet ls artist:@something``:: +One simple kind of query you can extend is the ``RegisteredFieldQuery``, which +implements string comparisons. To use it, create a subclass inheriting from +that class and override the ``value_match`` class method. (Remember the +``@classmethod`` decorator!) The following example plugin declares a query +using the ``@`` prefix to delimit exact string matches. The plugin will be +used if we issue a command like ``beet ls @something`` or ``beet ls +artist:@something``:: from beets.plugins import BeetsPlugin from beets.library import PluginQuery - class ExampleQuery(PluginQuery): + class ExactMatchQuery(PluginQuery): @classmethod def value_match(self, pattern, val): - return True # This will just match everything. + return pattern == val - class ExamplePlugin(BeetsPlugin): + class ExactMatchPlugin(BeetsPlugin): def queries(): return { - '@': ExampleQuery + '@': ExactMatchQuery } From 79c79bfcc3b7c029ddf6cfbcc9ae80f671311e18 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 14 Mar 2013 10:12:20 -0700 Subject: [PATCH 81/88] refactor query parsing It's long overdue that the parsing of individual query components was moved out of classes and into top-level functions, where it belongs. --- beets/library.py | 172 ++++++++++++++++++++++++----------------------- 1 file changed, 88 insertions(+), 84 deletions(-) diff --git a/beets/library.py b/beets/library.py index c8460955d..876b0961f 100644 --- a/beets/library.py +++ b/beets/library.py @@ -589,53 +589,6 @@ class CollectionQuery(Query): clause = (' ' + joiner + ' ').join(clause_parts) return clause, subvals - # Regular expression for _parse_query_part, below. - _pq_regex = re.compile( - # Non-capturing optional segment for the keyword. - r'(?:' - r'(\S+?)' # The field key. - r'(? Date: Thu, 14 Mar 2013 10:20:31 -0700 Subject: [PATCH 82/88] initial support for non-field queries With this change, we can get slightly closer to letting plugins extend the query syntax with queries that don't pertain to a specific field. This will likely need some more tweaking in the future, but it should allow for some very interesting things. --- beets/library.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/beets/library.py b/beets/library.py index 876b0961f..e33c5cbcd 100644 --- a/beets/library.py +++ b/beets/library.py @@ -768,9 +768,14 @@ def construct_query_part(query_part, default_fields, all_keys): if os.sep in pattern and 'path' in all_keys: # This looks like a path. return PathQuery(pattern) - else: - # Match any field. + elif issubclass(query_class, FieldQuery): + # The query type matches a specific field, but none was + # specified. So we use a version of the query that matches + # any field. return AnyFieldQuery(pattern, default_fields, query_class) + else: + # Other query type. + return query_class(pattern) # A boolean field. elif key.lower() == 'comp': From fe8092139b6eeb6c98b2c9ecb276ab944e1b61de Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 14 Mar 2013 11:05:35 -0700 Subject: [PATCH 83/88] fix construction of conditional path queries I broke this in the last flurry of refactors but, as is usually the case, I have no idea why it ever worked before. --- beets/library.py | 11 +++++------ test/test_query.py | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/beets/library.py b/beets/library.py index e33c5cbcd..29d1a6966 100644 --- a/beets/library.py +++ b/beets/library.py @@ -590,10 +590,9 @@ class CollectionQuery(Query): return clause, subvals @classmethod - def from_strings(cls, query_parts, default_fields=None, - all_keys=ITEM_KEYS): + def from_strings(cls, query_parts, default_fields, all_keys): """Creates a query from a list of strings in the format used by - _parse_query_part. If default_fields are specified, they are the + parse_query_part. If default_fields are specified, they are the fields to be searched by unqualified search terms. Otherwise, all fields are searched for those terms. """ @@ -607,7 +606,8 @@ class CollectionQuery(Query): return cls(subqueries) @classmethod - def from_string(cls, query, default_fields=None, all_keys=ITEM_KEYS): + def from_string(cls, query, default_fields=ITEM_DEFAULT_FIELDS, + all_keys=ITEM_KEYS): """Creates a query based on a single string. The string is split into query parts using shell-style syntax. """ @@ -617,8 +617,7 @@ class CollectionQuery(Query): if isinstance(query, unicode): query = query.encode('utf8') parts = [s.decode('utf8') for s in shlex.split(query)] - return cls.from_strings(parts, default_fields=default_fields, - all_keys=all_keys) + return cls.from_strings(parts, default_fields, all_keys) class AnyFieldQuery(CollectionQuery): """A query that matches if a given FieldQuery subclass matches in diff --git a/test/test_query.py b/test/test_query.py index 5c29ab03f..54b30cf1e 100644 --- a/test/test_query.py +++ b/test/test_query.py @@ -20,7 +20,7 @@ import _common from _common import unittest import beets.library -pqp = beets.library.CollectionQuery._parse_query_part +pqp = beets.library.parse_query_part some_item = _common.item() From 99cb4a171f64f83d6998ce7e235310e1b1951e4f Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 14 Mar 2013 18:34:55 -0700 Subject: [PATCH 84/88] the: fix config syntax in docs --- docs/plugins/the.rst | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/plugins/the.rst b/docs/plugins/the.rst index 32f9ece33..4d807e113 100644 --- a/docs/plugins/the.rst +++ b/docs/plugins/the.rst @@ -22,18 +22,18 @@ The default configuration moves all English articles to the end of the string, but you can override these defaults to make more complex changes:: the: - # handle The, default is on - the=yes - # handle A/An, default is on - a=yes + # handle "The" (on by default) + the: yes + # handle "A/An" (on by default) + a: yes # format string, {0} - part w/o article, {1} - article # spaces already trimmed from ends of both parts # default is '{0}, {1}' - format={0}, {1} + format: '{0}, {1}' # strip instead of moving to the end, default is off - strip=no - # custom regexp patterns, separated by space - patterns= + strip: no + # custom regexp patterns, space-separated + patterns: ... Custom patterns are case-insensitive regular expressions. Patterns can be matched anywhere in the string (not just the beginning), so use ``^`` if you From 3e1a181d3a632a1943647a8ce242b8e7a6838e41 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 14 Mar 2013 18:43:44 -0700 Subject: [PATCH 85/88] fix extra super call in fuzzy This was dumb of me. --- beetsplug/fuzzy.py | 1 - 1 file changed, 1 deletion(-) diff --git a/beetsplug/fuzzy.py b/beetsplug/fuzzy.py index 14ae54ffc..b6ad90d87 100644 --- a/beetsplug/fuzzy.py +++ b/beetsplug/fuzzy.py @@ -39,7 +39,6 @@ class FuzzyPlugin(BeetsPlugin): 'prefix': '~', 'threshold': 0.7, }) - super(FuzzyPlugin, self).__init__(self) def queries(self): prefix = beets.config['fuzzy']['prefix'].get(basestring) From ab69cfb1f9d1567b7b6133134b0ab290fb454c82 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Fri, 15 Mar 2013 10:26:58 -0700 Subject: [PATCH 86/88] fix typo in web screenshot (closes #222) --- docs/plugins/beetsweb.png | Bin 21945 -> 36607 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/plugins/beetsweb.png b/docs/plugins/beetsweb.png index 7b04d95867bee8243fef711af55d01a945c21e70..c335104eb86d66e48cda9a311ce8793eaeadf259 100644 GIT binary patch literal 36607 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 From 7383da127d1ef2a3d05bed268937ea6ed0e05833 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 16 Mar 2013 10:44:47 -0700 Subject: [PATCH 87/88] changelog summary/date for 1.1b3 --- docs/changelog.rst | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index b00876395..b75c3728e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,9 +1,16 @@ Changelog ========= -1.1b3 (in development) +1.1b3 (March 16, 2013) ---------------------- +This third beta of beets 1.1 brings a hodgepodge of little new features (and +internal overhauls that will make improvements easier in the future). There +are new options for getting metadata in a particular language and seeing more +detail during the import process. There's also a new plugin for synchronizing +your metadata with MusicBrainz. Under the hood, plugins can now extend the +query syntax. + New configuration options: * :ref:`languages` controls the preferred languages when selecting an alias From a9ca438f82a24b1e951703bc5d5f6b418721de1f Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 16 Mar 2013 10:45:23 -0700 Subject: [PATCH 88/88] Added tag v1.1.0-beta.3 for changeset 8f070ce28a7b --- .hgtags | 1 + 1 file changed, 1 insertion(+) diff --git a/.hgtags b/.hgtags index 2cf45afe0..914d3b40b 100644 --- a/.hgtags +++ b/.hgtags @@ -17,3 +17,4 @@ c84744f4519be7416dc1653142f1763f406d6896 1.0rc1 f3cd4c138c6f40dc324a23bf01c4c7d97766477e 1.0rc2 6f29c0f4dc7025e8d8216ea960000c353886c4f4 v1.1.0-beta.1 f28ea9e2ef8d39913d79dbba73db280ff0740c50 v1.1.0-beta.2 +8f070ce28a7b33d8509b29a8dbe937109bbdbd21 v1.1.0-beta.3