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: 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 diff --git a/beets/library.py b/beets/library.py index 390d4e1dd..29d1a6966 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 @@ -469,12 +454,51 @@ 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. Subclasses also need to provide `clause` to implement the + same matching functionality in SQLite. """ def __init__(self, field, pattern): self.field = field self.pattern = 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._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__, + field=self.field) + return clause, [self.pattern] + + @classmethod + def register(cls, conn): + """Register this query's matching function with the SQLite + connection. This method should only be invoked when the query + type chooses not to override `clause`. + """ + 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): @@ -483,8 +507,11 @@ class MatchQuery(FieldQuery): pattern = buffer(pattern) return self.field + " = ?", [pattern] - def match(self, item): - return self.pattern == getattr(item, self.field) + # 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.""" @@ -495,24 +522,22 @@ class SubstringQuery(FieldQuery): subvals = [search] return clause, subvals - def match(self, item): - value = util.as_string(getattr(item, self.field)) - return self.pattern.lower() in value.lower() + @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 - - def match(self, item): - value = util.as_string(getattr(item, self.field)) - return self.regexp.search(value) is not None +class RegexpQuery(RegisteredFieldQuery): + """A query that matches a regular expression in a specific item + field. + """ + @classmethod + def value_match(cls, 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 @@ -564,103 +589,25 @@ 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'(?= 1000 * maxbr + + +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' + dest = _destination(lib, dest_dir, item, keep_new) if os.path.exists(util.syspath(dest)): log.info(u'Skipping {0} (target file exists)'.format( @@ -66,16 +88,40 @@ def convert_item(lib, dest_dir): with _fs_lock: util.mkdirall(dest) - 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) - else: - encode(item.path, 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) - item.path = dest + if not should_transcode(item): + # No transcoding necessary. + log.info(u'Copying {0}'.format(util.displayable_path(item.path))) + if keep_new: + util.copy(dest, item.path) + else: + util.copy(item.path, dest) + + else: + if keep_new: + item.path = os.path.splitext(item.path)[0] + '.mp3' + encode(dest, item.path) + else: + encode(item.path, dest) + + # Write tags from the database to the converted file. + if not 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: @@ -84,13 +130,30 @@ def convert_item(lib, dest_dir): _embed(artpath, [item]) +def convert_on_import(lib, item): + """Transcode a file automatically after it is imported into the + library. + """ + if should_transcode(item): + fd, dest = tempfile.mkstemp('.mp3') + os.close(fd) + _temp_files.append(dest) # Delete the transcode later. + encode(item.path, dest) + item.path = dest + item.write() + item.read() # Load new audio information data. + lib.store(item) + + def convert_func(lib, opts, args): dest = opts.dest if opts.dest is not None else \ 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 ui.commands.list_items(lib, ui.decargs(args), opts.album, None) @@ -101,7 +164,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,7 +179,9 @@ class ConvertPlugin(BeetsPlugin): u'opts': u'-aq 2', u'max_bitrate': 500, u'embed': True, + u'auto': False }) + self.import_stages = [self.auto_convert] def commands(self): cmd = ui.Subcommand('convert', help='convert to external location') @@ -125,7 +190,27 @@ 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 return [cmd] + + def auto_convert(self, config, task): + if self.config['auto'].get(): + 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 _temp_files: + if os.path.isfile(path): + util.remove(path) + _temp_files.remove(path) 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/beetsplug/fuzzy.py b/beetsplug/fuzzy.py index 74693fd4b..b6ad90d87 100644 --- a/beetsplug/fuzzy.py +++ b/beetsplug/fuzzy.py @@ -12,86 +12,34 @@ # 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.ui import Subcommand, decargs, print_obj -from beets.util.functemplate import Template -from beets import config +from beets.library import RegisteredFieldQuery +import beets import difflib -def fuzzy_score(queryMatcher, item): - queryMatcher.set_seq1(item) - return queryMatcher.quick_ratio() +class FuzzyQuery(RegisteredFieldQuery): + @classmethod + def value_match(self, pattern, val): + # smartcase + if pattern.islower(): + val = val.lower() + queryMatcher = difflib.SequenceMatcher(None, pattern, val) + threshold = beets.config['fuzzy']['threshold'].as_number() + return queryMatcher.quick_ratio() >= threshold -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__() + super(FuzzyPlugin, self).__init__() self.config.add({ + 'prefix': '~', 'threshold': 0.7, }) - def commands(self): - return [fuzzy_cmd] + def queries(self): + prefix = beets.config['fuzzy']['prefix'].get(basestring) + return {prefix: FuzzyQuery} diff --git a/beetsplug/importfeeds.py b/beetsplug/importfeeds.py index a45e6013d..f160bb9a7 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/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'] diff --git a/docs/changelog.rst b/docs/changelog.rst index 49ce4edfa..b00876395 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -8,13 +8,14 @@ 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 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: @@ -22,6 +23,20 @@ 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. +* :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. @@ -42,8 +57,14 @@ 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 + the Echo Nest library. +* :doc:`/plugins/chroma`: Catch Acoustid Web service errors when submitting + fingerprints. 1.1b2 (February 16, 2013) ------------------------- diff --git a/docs/plugins/beetsweb.png b/docs/plugins/beetsweb.png index 7b04d9586..c335104eb 100644 Binary files a/docs/plugins/beetsweb.png and b/docs/plugins/beetsweb.png differ diff --git a/docs/plugins/chroma.rst b/docs/plugins/chroma.rst index b24a4816a..9fea8cc96 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. @@ -78,6 +78,12 @@ 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 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: Submitting Fingerprints diff --git a/docs/plugins/convert.rst b/docs/plugins/convert.rst index 1295b6a04..7486f38dc 100644 --- a/docs/plugins/convert.rst +++ b/docs/plugins/convert.rst @@ -31,6 +31,12 @@ 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 ------------- @@ -51,6 +57,10 @@ 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. 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. diff --git a/docs/plugins/fuzzy.rst b/docs/plugins/fuzzy.rst index be659b386..3f4115168 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 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`). -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 config options that let you choose the prefix and the +threshold.:: fuzzy: threshold: 0.8 + prefix: '@' + +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 +bothers you, you can change the prefix in your config file. 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:: 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 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 diff --git a/docs/plugins/writing.rst b/docs/plugins/writing.rst index aee4f93bc..d92ea8374 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) @@ -323,3 +323,39 @@ to register it:: self.import_stages = [self.stage] def stage(self, config, task): print('Importing something!') + +.. _extend-query: + +Extend the Query Syntax +^^^^^^^^^^^^^^^^^^^^^^^ + +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. + +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. + +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 ExactMatchQuery(PluginQuery): + @classmethod + def value_match(self, pattern, val): + return pattern == val + + class ExactMatchPlugin(BeetsPlugin): + def queries(): + return { + '@': ExactMatchQuery + } 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: 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 []) diff --git a/test/test_query.py b/test/test_query.py index 8c7378518..54b30cf1e 100644 --- a/test/test_query.py +++ b/test/test_query.py @@ -20,82 +20,64 @@ 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() class QueryParseTest(unittest.TestCase): def test_one_basic_term(self): q = 'test' - r = (None, 'test', False) + r = (None, 'test', beets.library.SubstringQuery) self.assertEqual(pqp(q), r) def test_one_keyed_term(self): q = 'test:val' - r = ('test', 'val', False) + r = ('test', 'val', beets.library.SubstringQuery) self.assertEqual(pqp(q), r) def test_colon_at_end(self): q = 'test:' - r = (None, 'test:', False) + r = (None, 'test:', beets.library.SubstringQuery) 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', beets.library.SubstringQuery) 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): +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.