mirror of
https://github.com/beetbox/beets.git
synced 2026-01-03 14:32:55 +01:00
Merge pull request #1603 from jackwilsdon/add-hook-plugin
Add hook plugin (fixes #1561)
This commit is contained in:
commit
950e1b5e85
5 changed files with 260 additions and 0 deletions
62
beetsplug/hook.py
Normal file
62
beetsplug/hook.py
Normal 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)
|
||||
|
|
@ -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
76
docs/plugins/hook.rst
Normal 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}"
|
||||
|
|
@ -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
117
test/test_hook.py
Normal 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')
|
||||
Loading…
Reference in a new issue