Merge pull request #1603 from jackwilsdon/add-hook-plugin

Add hook plugin (fixes #1561)
This commit is contained in:
Adrian Sampson 2016-04-30 15:03:42 -07:00
commit 950e1b5e85
5 changed files with 260 additions and 0 deletions

62
beetsplug/hook.py Normal file
View file

@ -0,0 +1,62 @@
# This file is part of beets.
# Copyright 2015, Adrian Sampson.
#
# 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.
"""Allows custom commands to be run when an event is emitted by beets"""
from __future__ import division, absolute_import, print_function
import shlex
import subprocess
from beets.plugins import BeetsPlugin
from beets.ui import _arg_encoding
class HookPlugin(BeetsPlugin):
"""Allows custom commands to be run when an event is emitted by beets"""
def __init__(self):
super(HookPlugin, self).__init__()
self.config.add({
'hooks': []
})
hooks = self.config['hooks'].get(list)
for hook_index in range(len(hooks)):
hook = self.config['hooks'][hook_index]
hook_event = hook['event'].get()
hook_command = hook['command'].get()
self.create_and_register_hook(hook_event, hook_command)
def create_and_register_hook(self, event, command):
def hook_function(**kwargs):
if command is None or len(command) == 0:
self._log.error('invalid command "{0}"', command)
return
formatted_command = command.format(event=event, **kwargs)
encoded_command = formatted_command.decode(_arg_encoding())
command_pieces = shlex.split(encoded_command)
self._log.debug('Running command "{0}" for event {1}',
encoded_command, event)
try:
subprocess.Popen(command_pieces).wait()
except OSError as exc:
self._log.error('hook for {0} failed: {1}', event, exc)
self.register_listener(event, hook_function)

View file

@ -15,6 +15,7 @@ New features:
for a Microsoft Azure Marketplace free account. Thanks to :user:`Kraymer`.
* :doc:`/plugins/fetchart`: Album art can now be fetched from `fanart.tv`_.
Albums are matched using the ``mb_releasegroupid`` tag.
* :doc:`/plugins/fetchart`: The ``enforce_ratio`` option was enhanced and now
allows specifying a certain deviation that a valid image may have from being
exactly square.
@ -25,6 +26,8 @@ New features:
* :doc:`/reference/pathformat`: new functions: %first{} and %ifdef{}
* :doc:`/reference/config`: option ``terminal_encoding`` now works for some
inputs
* New :doc:`/plugins/hook` that allows commands to be executed when an event is
emitted by beets. :bug:`1561` :bug:`1603`
.. _fanart.tv: https://fanart.tv/

76
docs/plugins/hook.rst Normal file
View file

@ -0,0 +1,76 @@
Hook Plugin
===============
Internally, beets sends events to plugins when an action finishes. These can
range from importing a song (``import``) to beets exiting (``cli_exit``), and
provide a very flexible way to perform actions based on the events. This plugin
allows you to run commands when an event is emitted by beets, such as syncing
your library with another drive when the library is updated.
Hooks are currently run in the order defined in the configuration, however this
is dependent on beets itself and it's consistency should not be depended upon.
.. _hook-configuration:
Configuration
-------------
To configure the plugin, make a ``hook`` section in your configuration
file. The available options are:
- **hooks**: A list of events and the commands to run
(see :ref:`individual-hook-configuration`). Default: Empty.
.. _individual-hook-configuration:
Individual Hook Configuration
-----------------------------
Each element of the ``hooks`` configuration option can be configured separately.
The available options are:
- **event**: The name of the event that should cause this hook to
execute. See the :ref:`plugin events <plugin_events>` documentation for a list
of possible values.
- **command**: The command to run when this hook executes.
.. _command-substitution:
Command Substitution
--------------------
Certain key words can be replaced in commands, allowing access to event
information such as the path of an album or the name of a song. This information
is accessed using the syntax ``{property_name}``, where ``property_name`` is the
name of an argument passed to the event. ``property_name`` can also be a key on
an argument passed to the event, such as ``{album.path}``.
You can find a list of all available events and their arguments in the
:ref:`plugin events <plugin_events>` documentation.
Example Configuration
---------------------
.. code-block:: yaml
hook:
hooks:
# Output on exit:
# beets just exited!
# have a nice day!
- event: cli_exit
command: echo "beets just exited!"
- event: cli_exit
command: echo "have a nice day!"
# Output on item import:
# importing "<file_name_here>"
# Where <file_name_here> is the item being imported
- event: item_imported
command: echo "importing \"{item.path}\""
# Output on write:
# writing to "<file_name_here>"
# Where <file_name_here> is the file being written to
- event: write
command: echo "writing to {path}"

View file

@ -50,6 +50,7 @@ Each plugin has its own set of options that can be defined in a section bearing
ftintitle
fuzzy
freedesktop
hook
ihate
importadded
importfeeds
@ -164,6 +165,7 @@ Miscellaneous
* :doc:`duplicates`: List duplicate tracks or albums.
* :doc:`export`: Export data from queries to a format.
* :doc:`fuzzy`: Search albums and tracks with fuzzy string matching.
* :doc:`hook`: Run a command when an event is emitted by beets.
* :doc:`ihate`: Automatically skip albums and tracks during the import process.
* :doc:`info`: Print music files' tags to the console.
* :doc:`mbcollection`: Maintain your MusicBrainz collection list.

117
test/test_hook.py Normal file
View file

@ -0,0 +1,117 @@
# This file is part of beets.
# Copyright 2015, Thomas Scholtes.
#
# 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.
from __future__ import division, absolute_import, print_function
import os.path
import tempfile
from test import _common
from test._common import unittest
from test.helper import TestHelper
from beets import config
from beets import plugins
def get_temporary_path():
temporary_directory = tempfile._get_default_tempdir()
temporary_name = next(tempfile._get_candidate_names())
return os.path.join(temporary_directory, temporary_name)
class HookTest(_common.TestCase, TestHelper):
TEST_HOOK_COUNT = 5
def setUp(self):
self.setup_beets() # Converter is threaded
def tearDown(self):
self.unload_plugins()
self.teardown_beets()
def _add_hook(self, event, command):
hook = {
'event': event,
'command': command
}
hooks = config['hook']['hooks'].get(list) if 'hook' in config else []
hooks.append(hook)
config['hook']['hooks'] = hooks
def test_hook_no_arguments(self):
temporary_paths = [
get_temporary_path() for i in range(self.TEST_HOOK_COUNT)
]
for index, path in enumerate(temporary_paths):
self._add_hook('test_no_argument_event_{0}'.format(index),
'touch "{0}"'.format(path))
self.load_plugins('hook')
for index in range(len(temporary_paths)):
plugins.send('test_no_argument_event_{0}'.format(index))
for path in temporary_paths:
self.assertTrue(os.path.isfile(path))
os.remove(path)
def test_hook_event_substitution(self):
temporary_directory = tempfile._get_default_tempdir()
event_names = ['test_event_event_{0}'.format(i) for i in
range(self.TEST_HOOK_COUNT)]
for event in event_names:
self._add_hook(event,
'touch "{0}/{{event}}"'.format(temporary_directory))
self.load_plugins('hook')
for event in event_names:
plugins.send(event)
for event in event_names:
path = os.path.join(temporary_directory, event)
self.assertTrue(os.path.isfile(path))
os.remove(path)
def test_hook_argument_substitution(self):
temporary_paths = [
get_temporary_path() for i in range(self.TEST_HOOK_COUNT)
]
for index, path in enumerate(temporary_paths):
self._add_hook('test_argument_event_{0}'.format(index),
'touch "{path}"')
self.load_plugins('hook')
for index, path in enumerate(temporary_paths):
plugins.send('test_argument_event_{0}'.format(index), path=path)
for path in temporary_paths:
self.assertTrue(os.path.isfile(path))
os.remove(path)
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)
if __name__ == b'__main__':
unittest.main(defaultTest='suite')