Repository: beetbox/beets

Issue:
Let beets execute a shell script after import/update
Hey, this is a feature request. I don't know how to label it accordingly.

I ran into the problem that I would like to automate my update process for my music system. This system is basically using beets in combination with KODI and kplaylist. At least kplaylist can be triggered from the command line to update its database. I guess KODI should be able to be triggered, too. So I searched for an option to execute a shell script every time beets imported or updated the database. In such a script I could call respective trigger commands for KODI and kplaylist.

AFAIK there is nothing around to solve this. If there is, I'd be happy to get a pointer to it. Otherwise, it might be a good idea to be implemented.

Thanks! :)



Discussion:
What about using `importfeeds` plugin, and using a tool like [maid](https://github.com/benjaminoakes/maid) to watch its output folder and acts accordingly?  
I have a similar setup on mac (hazel instead of maid) to import new tunes to itunes and it works like a charm.

Thanks for the idea. I will have a look into this. 

Thanks, @Kraymer! Seems like a worthy feature request in any case—probably a plugin that can run shell commands on any beets event.

What's wrong with writing a shellscript that executes beets and those commands sequently?

On 8 August 2015 02:18:20 CEST, Adrian Sampson notifications@github.com wrote:

> Thanks, @Kraymer! Seems like a worthy feature request in any
> case—probably a plugin that can run shell commands on any beets event.
> 
> ---
> 
> Reply to this email directly or view it on GitHub:
> https://github.com/sampsyo/beets/issues/1561#issuecomment-128866488

## 

Sent from my phone. Please excuse my brevity.

Good point. For the case where you just want to run after an import, a wrapper script/alias seems like the right solution.

FWIW a plugin to replace beet's UI with machine readable output (printing raw filepaths etc, no colors) would be nice. But I'm not sure that's necessary for OP's usecase.

A simple wrapper script would be e.g. /usr/local/bin/beet:

```
#!/bin/bash
SCRIPT=/usr/local/bin/beet_follow_import.sh
/usr/bin/beet "$@"
if [ "$?" = 0 ] && [ "$1" = "import" ]; then
        echo Executing script "$SCRIPT"
        "$SCRIPT"
fi
exit 0
```

I don't think it's necessary to globally override the beet binary.

On Sat, Aug 08, 2015 at 01:37:42PM -0700, marlemion wrote:

> A simple wrapper script would be e.g. /usr/local/bin/beet:
> 
> #!/bin/bash
> SCRIPT=/usr/local/bin/beet_follow_import.sh
> /usr/bin/beet "$@"
> if [ "$?" = 0 ] && [ "$1" = "import" ]; then
>         echo Executing script "$SCRIPT"
>         "$SCRIPT"
> fi
> exit 0
> 
> ---
> 
> Reply to this email directly or view it on GitHub:
> https://github.com/sampsyo/beets/issues/1561#issuecomment-129043127

To implement the desired use case, it is necessary imho.

I have actually cobbled together a plugin to do this a while ago. I am fairly busy right now, but would offer to create a pull request one of these days.

Edit: This might also be a solution for #1191 

Awesome! Good point about that Mopidy hook, @m-urban.

Just created PR #1573 to run scripts after import. I don't think we have a hook for `beet update` yet, do we?

## I still don't see why this is necessary though?

Sent from my phone. Please excuse my brevity.

@untitaker I can imagine this being useful if you want to sync your beets configuration across machines. Currently I use beets with git-annex, and I have to run `git annex sync --content` every time I import music or change my library. It would be nice to have a plugin that allows hooking into beets events, so it could run `git annex sync --content` automatically for me.


Resolving patch:
diff --git a/beetsplug/hook.py b/beetsplug/hook.py
new file mode 100644
--- /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)


Test patch:
diff --git a/test/test_hook.py b/test/test_hook.py
new file mode 100644
--- /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')