From 5a8880e86fb1c0f32de3de84e2439f9f2da0a7a6 Mon Sep 17 00:00:00 2001 From: Lucas Duailibe Date: Wed, 6 Mar 2013 19:59:08 -0300 Subject: [PATCH 01/58] 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 02/58] 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 03/58] 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 04/58] 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 05/58] 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 06/58] 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 07/58] 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 08/58] 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 09/58] 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 10/58] 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 11/58] 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 12/58] 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 13/58] 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 14/58] 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 15/58] 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 16/58] 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 17/58] 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 18/58] 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 19/58] 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 20/58] 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 21/58] 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 22/58] 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 23/58] 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 24/58] 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 25/58] 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 26/58] 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 27/58] 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 28/58] 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 29/58] 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 30/58] 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 31/58] 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 32/58] 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 33/58] 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 34/58] 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 35/58] 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 36/58] 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 37/58] 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 38/58] 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 39/58] 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 40/58] 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 41/58] 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 42/58] 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 43/58] 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 44/58] 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 45/58] 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 46/58] 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 47/58] 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 48/58] 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 49/58] 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 50/58] 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 51/58] 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 52/58] 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 53/58] 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 54/58] 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 55/58] 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 56/58] 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 57/58] 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 58/58] 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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