Skip to content

Commit

Permalink
Merge 7c9440c into 48fff93
Browse files Browse the repository at this point in the history
  • Loading branch information
jackwilsdon committed Apr 28, 2016
2 parents 48fff93 + 7c9440c commit 2a5f9be
Show file tree
Hide file tree
Showing 5 changed files with 260 additions and 0 deletions.
62 changes: 62 additions & 0 deletions beetsplug/hook.py
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 3 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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/

Expand Down
76 changes: 76 additions & 0 deletions docs/plugins/hook.rst
Original file line number Diff line number Diff line change
@@ -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}"
2 changes: 2 additions & 0 deletions docs/plugins/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
117 changes: 117 additions & 0 deletions test/test_hook.py
Original file line number Diff line number Diff line change
@@ -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')

0 comments on commit 2a5f9be

Please sign in to comment.