diff --git a/beetsplug/hook.py b/beetsplug/hook.py new file mode 100644 index 0000000000..81fb9a7f9e --- /dev/null +++ b/beetsplug/hook.py @@ -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) diff --git a/docs/changelog.rst b/docs/changelog.rst index 1844c4d872..3c7ea6ee28 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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. @@ -23,6 +24,8 @@ New features: * :doc:`/plugins/export`: A new plugin to export the data from queries to a json format. Thanks to :user:`GuilhermeHideki`. * :doc:`/reference/pathformat`: new functions: %first{} and %ifdef{} +* 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/ diff --git a/docs/plugins/hook.rst b/docs/plugins/hook.rst new file mode 100644 index 0000000000..2de736c7c3 --- /dev/null +++ b/docs/plugins/hook.rst @@ -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 ` 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 ` 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 "" + # Where is the item being imported + - event: item_imported + command: echo "importing \"{item.path}\"" + + # Output on write: + # writing to "" + # Where is the file being written to + - event: write + command: echo "writing to {path}" diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 24f2619639..005b693e4f 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -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. diff --git a/test/test_hook.py b/test/test_hook.py new file mode 100644 index 0000000000..fc4e7a7eef --- /dev/null +++ b/test/test_hook.py @@ -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')