Merge pull request #1758 from diego-plan9/prompthook

Add event for adding importer options + mbsubmit plugin
This commit is contained in:
Adrian Sampson 2015-12-22 16:58:15 -08:00
commit 9bddeceb9a
4 changed files with 370 additions and 13 deletions

View file

@ -22,6 +22,8 @@ from __future__ import (division, absolute_import, print_function,
import os
import re
from collections import namedtuple, Counter
from itertools import chain
import beets
from beets import ui
@ -39,6 +41,7 @@ from beets import logging
from beets.util.confit import _package_path
VARIOUS_ARTISTS = u'Various Artists'
PromptChoice = namedtuple('ExtraChoice', ['short', 'long', 'callback'])
# Global logger.
log = logging.getLogger('beets')
@ -471,7 +474,8 @@ def _summary_judment(rec):
def choose_candidate(candidates, singleton, rec, cur_artist=None,
cur_album=None, item=None, itemcount=None):
cur_album=None, item=None, itemcount=None,
extra_choices=[]):
"""Given a sorted list of candidates, ask the user for a selection
of which candidate to use. Applies to both full albums and
singletons (tracks). Candidates are either AlbumMatch or TrackMatch
@ -479,8 +483,16 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None,
`cur_album`, and `itemcount` must be provided. For singletons,
`item` must be provided.
Returns the result of the choice, which may SKIP, ASIS, TRACKS, or
MANUAL or a candidate (an AlbumMatch/TrackMatch object).
`extra_choices` is a list of `PromptChoice`s, containg the choices
appended by the plugins after receiving the `before_choose_candidate`
event. If not empty, the choices are appended to the prompt presented
to the user.
Returns one of the following:
* the result of the choice, which may be SKIP, ASIS, TRACKS, or MANUAL
* a candidate (an AlbumMatch/TrackMatch object)
* the short letter of a `PromptChoice` (if the user selected one of
the `extra_choices`).
"""
# Sanity check.
if singleton:
@ -489,6 +501,10 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None,
assert cur_artist is not None
assert cur_album is not None
# Build helper variables for extra choices.
extra_opts = tuple(c.long for c in extra_choices)
extra_actions = tuple(c.short for c in extra_choices)
# Zero candidates.
if not candidates:
if singleton:
@ -502,7 +518,7 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None,
'http://beets.readthedocs.org/en/latest/faq.html#nomatch')
opts = ('Use as-is', 'as Tracks', 'Group albums', 'Skip',
'Enter search', 'enter Id', 'aBort')
sel = ui.input_options(opts)
sel = ui.input_options(opts + extra_opts)
if sel == 'u':
return importer.action.ASIS
elif sel == 't':
@ -518,6 +534,8 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None,
return importer.action.MANUAL_ID
elif sel == 'g':
return importer.action.ALBUMS
elif sel in extra_actions:
return sel
else:
assert False
@ -571,7 +589,8 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None,
else:
opts = ('Skip', 'Use as-is', 'as Tracks', 'Group albums',
'Enter search', 'enter Id', 'aBort')
sel = ui.input_options(opts, numrange=(1, len(candidates)))
sel = ui.input_options(opts + extra_opts,
numrange=(1, len(candidates)))
if sel == 's':
return importer.action.SKIP
elif sel == 'u':
@ -589,6 +608,8 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None,
return importer.action.MANUAL_ID
elif sel == 'g':
return importer.action.ALBUMS
elif sel in extra_actions:
return sel
else: # Numerical selection.
match = candidates[sel - 1]
if sel != 1:
@ -623,7 +644,8 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None,
})
if default is None:
require = True
sel = ui.input_options(opts, require=require, default=default)
sel = ui.input_options(opts + extra_opts, require=require,
default=default)
if sel == 'a':
return match
elif sel == 'g':
@ -641,6 +663,8 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None,
raise importer.ImportAbort()
elif sel == 'i':
return importer.action.MANUAL_ID
elif sel in extra_actions:
return sel
def manual_search(singleton):
@ -684,10 +708,14 @@ class TerminalImportSession(importer.ImportSession):
# Loop until we have a choice.
candidates, rec = task.candidates, task.rec
while True:
# Gather extra choices from plugins.
extra_choices = self._get_plugin_choices(task)
extra_ops = {c.short: c.callback for c in extra_choices}
# Ask for a choice from the user.
choice = choose_candidate(
candidates, False, rec, task.cur_artist, task.cur_album,
itemcount=len(task.items)
itemcount=len(task.items), extra_choices=extra_choices
)
# Choose which tags to use.
@ -708,6 +736,12 @@ class TerminalImportSession(importer.ImportSession):
_, _, candidates, rec = autotag.tag_album(
task.items, search_id=search_id
)
elif choice in extra_ops.keys():
# Allow extra ops to automatically set the post-choice.
post_choice = extra_ops[choice](self, task)
if isinstance(post_choice, importer.action):
# MANUAL and MANUAL_ID have no effect, even if returned.
return post_choice
else:
# We have a candidate! Finish tagging. Here, choice is an
# AlbumMatch object.
@ -732,8 +766,12 @@ class TerminalImportSession(importer.ImportSession):
return action
while True:
extra_choices = self._get_plugin_choices(task)
extra_ops = {c.short: c.callback for c in extra_choices}
# Ask for a choice.
choice = choose_candidate(candidates, True, rec, item=task.item)
choice = choose_candidate(candidates, True, rec, item=task.item,
extra_choices=extra_choices)
if choice in (importer.action.SKIP, importer.action.ASIS):
return choice
@ -750,6 +788,12 @@ class TerminalImportSession(importer.ImportSession):
if search_id:
candidates, rec = autotag.tag_item(task.item,
search_id=search_id)
elif choice in extra_ops.keys():
# Allow extra ops to automatically set the post-choice.
post_choice = extra_ops[choice](self, task)
if isinstance(post_choice, importer.action):
# MANUAL and MANUAL_ID have no effect, even if returned.
return post_choice
else:
# Chose a candidate.
assert isinstance(choice, autotag.TrackMatch)
@ -801,6 +845,48 @@ class TerminalImportSession(importer.ImportSession):
"was interrupted. Resume (Y/n)?"
.format(displayable_path(path)))
def _get_plugin_choices(self, task):
"""Get the extra choices appended to the plugins to the ui prompt.
The `before_choose_candidate` event is sent to the plugins, with
session and task as its parameters. Plugins are responsible for
checking the right conditions and returning a list of `PromptChoice`s,
which is flattened and checked for conflicts.
Raises `ValueError` if two of the choices have the same short letter.
Returns a list of `PromptChoice`s.
"""
# Send the before_choose_candidate event and flatten list.
extra_choices = list(chain(*plugins.send('before_choose_candidate',
session=self, task=task)))
# Take into account default options, for duplicate checking.
all_choices = [PromptChoice('a', 'Apply', None),
PromptChoice('s', 'Skip', None),
PromptChoice('u', 'Use as-is', None),
PromptChoice('t', 'as Tracks', None),
PromptChoice('g', 'Group albums', None),
PromptChoice('e', 'Enter search', None),
PromptChoice('i', 'enter Id', None),
PromptChoice('b', 'aBort', None)] +\
extra_choices
short_letters = [c.short for c in all_choices]
if len(short_letters) != len(set(short_letters)):
# Duplicate short letter has been found.
duplicates = [i for i, count in Counter(short_letters).items()
if count > 1]
for short in duplicates:
# Keep the first of the choices, removing the rest.
dup_choices = [c for c in all_choices if c.short == short]
for c in dup_choices[1:]:
log.warn(u"Prompt choice '{0}' removed due to conflict "
u"with '{1}' (short letter: '{2}')",
c.long, dup_choices[0].long, c.short)
extra_choices.remove(c)
return extra_choices
# The import command.

55
beetsplug/mbsubmit.py Normal file
View file

@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2015, Adrian Sampson and Diego Moreda.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""Aid in submitting information to MusicBrainz.
This plugin allows the user to print track information in a format that is
parseable by the MusicBrainz track parser. Programmatic submitting is not
implemented by MusicBrainz yet.
"""
from __future__ import (division, absolute_import, print_function,
unicode_literals)
from beets.autotag import Recommendation
from beets.importer import action
from beets.plugins import BeetsPlugin
from beets.ui.commands import PromptChoice
from beetsplug.info import print_data
class MBSubmitPlugin(BeetsPlugin):
def __init__(self):
super(MBSubmitPlugin, self).__init__()
self.register_listener('before_choose_candidate',
self.before_choose_candidate_event)
def before_choose_candidate_event(self, session, task):
if not task.candidates or task.rec == Recommendation.none:
return [PromptChoice('p', 'Print tracks', self.print_tracks),
PromptChoice('k', 'print tracks and sKip',
self.print_tracks_and_skip)]
# Callbacks for choices.
def print_tracks(self, session, task):
for i in task.items:
print_data(None, i, '$track. $artist - $title ($length)')
def print_tracks_and_skip(self, session, task):
for i in task.items:
print_data(None, i, '$track. $artist - $title ($length)')
return action.SKIP

View file

@ -227,12 +227,17 @@ The events currently available are:
of a ``TrackInfo``.
Parameter: ``info``.
* `before_choose_candidate`: called before the user is prompted for a decision
during a ``beet import`` interactive session. Plugins can use this event for
:ref:`appending choices to the prompt <append_prompt_choices>` by returning a
list of ``PromptChoices``. Parameters: ``task`` and ``session``.
The included ``mpdupdate`` plugin provides an example use case for event listeners.
Extend the Autotagger
^^^^^^^^^^^^^^^^^^^^^
Plugins in can also enhance the functionality of the autotagger. For a
Plugins can also enhance the functionality of the autotagger. For a
comprehensive example, try looking at the ``chroma`` plugin, which is included
with beets.
@ -528,3 +533,64 @@ command and an import stage, but the command needs to print more messages than
the import stage. (For example, you'll want to log "found lyrics for this song"
when you're run explicitly as a command, but you don't want to noisily
interrupt the importer interface when running automatically.)
.. _append_prompt_choices:
Append Prompt Choices
^^^^^^^^^^^^^^^^^^^^^
Plugins can also append choices to the prompt presented to the user during
an import session.
To do so, add a listener for the ``before_choose_candidate`` event, and return
a list of ``PromptChoices`` that represent the additional choices that your
plugin shall expose to the user::
from beets.plugins import BeetsPlugin
from beets.ui.commands import PromptChoice
class ExamplePlugin(BeetsPlugin):
def __init__(self):
super(ExamplePlugin, self).__init__()
self.register_listener('before_choose_candidate',
self.before_choose_candidate_event)
def before_choose_candidate_event(self, session, task):
return [PromptChoice('p', 'Print foo', self.foo),
PromptChoice('d', 'Do bar', self.bar)]
def foo(self, session, task):
print('User has chosen "Print foo"!')
def bar(self, session, task):
print('User has chosen "Do bar"!')
The previous example modifies the standard prompt::
# selection (default 1), Skip, Use as-is, as Tracks, Group albums,
Enter search, enter Id, aBort?
by appending two additional options (``Print foo`` and ``Do bar``)::
# selection (default 1), Skip, Use as-is, as Tracks, Group albums,
Enter search, enter Id, aBort, Print foo, Do bar?
If the user selects a choice, the ``callback`` attribute of the corresponding
``PromptChoice`` will be called. It is the responsibility of the plugin to
check for the status of the import session and decide the choices to be
appended: for example, if a particular choice should only be presented if the
album has no candidates, the relevant checks against ``task.candidates`` should
be performed inside the plugin's ``before_choose_candidate_event`` accordingly.
Please make sure that the short letter for each of the choices provided by the
plugin is not already in use: the importer will raise an exception if two or
more choices try to use the same short letter. As a reference, the following
characters are used by the choices on the core importer prompt, and hence
should not be used: ``a``, ``s``, ``u``, ``t``, ``g``, ``e``, ``i``, ``b``.
Additionally, the callback function can optionally specify the next action to
be performed by returning one of the values from ``importer.action``, which
will be passed to the main loop upon the callback has been processed. Note that
``action.MANUAL`` and ``action.MANUAL_ID`` will have no effect even if
returned by the callback, due to the current architecture of the import
process.

View file

@ -17,17 +17,18 @@ from __future__ import (division, absolute_import, print_function,
unicode_literals)
import os
from mock import patch, Mock
from mock import patch, Mock, ANY
import shutil
import itertools
from beets.importer import SingletonImportTask, SentinelImportTask, \
ArchiveImportTask
from beets import plugins, config
ArchiveImportTask, action
from beets import plugins, config, ui
from beets.library import Item
from beets.dbcore import types
from beets.mediafile import MediaFile
from test.test_importer import ImportHelper
from test.test_importer import ImportHelper, AutotagStub
from test.test_ui_importer import TerminalImportSessionSetup
from test._common import unittest, RSRC
from test import helper
@ -401,6 +402,155 @@ class ListenersTest(unittest.TestCase, TestHelper):
plugins.send('event9', foo=5)
class PromptChoicesTest(TerminalImportSessionSetup, unittest.TestCase,
ImportHelper, TestHelper):
def setUp(self):
self.setup_plugin_loader()
self.setup_beets()
self._create_import_dir(3)
self._setup_import_session()
self.matcher = AutotagStub().install()
# keep track of ui.input_option() calls
self.input_options_patcher = patch('beets.ui.input_options',
side_effect=ui.input_options)
self.mock_input_options = self.input_options_patcher.start()
def tearDown(self):
self.input_options_patcher.stop()
self.teardown_plugin_loader()
self.teardown_beets()
def test_plugin_choices_in_ui_input_options_album(self):
"""Test the presence of plugin choices on the prompt (album)."""
class DummyPlugin(plugins.BeetsPlugin):
def __init__(self):
super(DummyPlugin, self).__init__()
self.register_listener('before_choose_candidate',
self.return_choices)
def return_choices(self, session, task):
return [ui.commands.PromptChoice('f', 'Foo', None),
ui.commands.PromptChoice('r', 'baR', None)]
self.register_plugin(DummyPlugin)
# Default options + extra choices by the plugin ('Foo', 'Bar')
opts = (u'Apply', u'More candidates', u'Skip', u'Use as-is',
u'as Tracks', u'Group albums', u'Enter search',
u'enter Id', u'aBort') + ('Foo', 'baR')
self.importer.add_choice(action.SKIP)
self.importer.run()
self.mock_input_options.assert_called_once_with(opts, default='a',
require=ANY)
def test_plugin_choices_in_ui_input_options_singleton(self):
"""Test the presence of plugin choices on the prompt (singleton)."""
class DummyPlugin(plugins.BeetsPlugin):
def __init__(self):
super(DummyPlugin, self).__init__()
self.register_listener('before_choose_candidate',
self.return_choices)
def return_choices(self, session, task):
return [ui.commands.PromptChoice('f', 'Foo', None),
ui.commands.PromptChoice('r', 'baR', None)]
self.register_plugin(DummyPlugin)
# Default options + extra choices by the plugin ('Foo', 'Bar')
opts = (u'Apply', u'More candidates', u'Skip', u'Use as-is',
u'Enter search',
u'enter Id', u'aBort') + ('Foo', 'baR')
config['import']['singletons'] = True
self.importer.add_choice(action.SKIP)
self.importer.run()
self.mock_input_options.assert_called_with(opts, default='a',
require=ANY)
def test_choices_conflicts(self):
"""Test the short letter conflict solving."""
class DummyPlugin(plugins.BeetsPlugin):
def __init__(self):
super(DummyPlugin, self).__init__()
self.register_listener('before_choose_candidate',
self.return_choices)
def return_choices(self, session, task):
return [ui.commands.PromptChoice('a', 'A foo', None), # dupe
ui.commands.PromptChoice('z', 'baZ', None), # ok
ui.commands.PromptChoice('z', 'Zupe', None), # dupe
ui.commands.PromptChoice('z', 'Zoo', None)] # dupe
self.register_plugin(DummyPlugin)
# Default options + not dupe extra choices by the plugin ('baZ')
opts = (u'Apply', u'More candidates', u'Skip', u'Use as-is',
u'as Tracks', u'Group albums', u'Enter search',
u'enter Id', u'aBort') + ('baZ',)
self.importer.add_choice(action.SKIP)
self.importer.run()
self.mock_input_options.assert_called_once_with(opts, default='a',
require=ANY)
def test_plugin_callback(self):
"""Test that plugin callbacks are being called upon user choice."""
class DummyPlugin(plugins.BeetsPlugin):
def __init__(self):
super(DummyPlugin, self).__init__()
self.register_listener('before_choose_candidate',
self.return_choices)
def return_choices(self, session, task):
return [ui.commands.PromptChoice('f', 'Foo', self.foo)]
def foo(self, session, task):
pass
self.register_plugin(DummyPlugin)
# Default options + extra choices by the plugin ('Foo', 'Bar')
opts = (u'Apply', u'More candidates', u'Skip', u'Use as-is',
u'as Tracks', u'Group albums', u'Enter search',
u'enter Id', u'aBort') + ('Foo',)
# DummyPlugin.foo() should be called once
with patch.object(DummyPlugin, 'foo', autospec=True) as mock_foo:
with helper.control_stdin('\n'.join(['f', 's'])):
self.importer.run()
self.assertEqual(mock_foo.call_count, 1)
# input_options should be called twice, as foo() returns None
self.assertEqual(self.mock_input_options.call_count, 2)
self.mock_input_options.assert_called_with(opts, default='a',
require=ANY)
def test_plugin_callback_return(self):
"""Test that plugin callbacks that return a value exit the loop."""
class DummyPlugin(plugins.BeetsPlugin):
def __init__(self):
super(DummyPlugin, self).__init__()
self.register_listener('before_choose_candidate',
self.return_choices)
def return_choices(self, session, task):
return [ui.commands.PromptChoice('f', 'Foo', self.foo)]
def foo(self, session, task):
return action.SKIP
self.register_plugin(DummyPlugin)
# Default options + extra choices by the plugin ('Foo', 'Bar')
opts = (u'Apply', u'More candidates', u'Skip', u'Use as-is',
u'as Tracks', u'Group albums', u'Enter search',
u'enter Id', u'aBort') + ('Foo',)
# DummyPlugin.foo() should be called once
with helper.control_stdin('f\n'):
self.importer.run()
# input_options should be called once, as foo() returns SKIP
self.mock_input_options.assert_called_once_with(opts, default='a',
require=ANY)
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)