From 0bf7c06f7d86577c0e4c9506e0d0d0088f036ddb Mon Sep 17 00:00:00 2001 From: Thomas Scholtes Date: Wed, 17 Sep 2014 12:05:17 +0200 Subject: [PATCH 1/2] Media file tags can be customized with the ``write`` event --- beets/library.py | 5 ++-- docs/changelog.rst | 2 ++ docs/dev/plugins.rst | 13 ++++++---- test/test_plugins.py | 56 +++++++++++++++++++++++++++++++++++++++----- 4 files changed, 63 insertions(+), 13 deletions(-) diff --git a/beets/library.py b/beets/library.py index c82f0224e..9f3ed36f8 100644 --- a/beets/library.py +++ b/beets/library.py @@ -473,7 +473,8 @@ class Item(LibModel): else: path = normpath(path) - plugins.send('write', item=self, path=path) + tags = dict(self) + plugins.send('write', item=self, path=path, tags=tags) try: mediafile = MediaFile(syspath(path), @@ -481,7 +482,7 @@ class Item(LibModel): except (OSError, IOError, UnreadableFileError) as exc: raise ReadError(self.path, exc) - mediafile.update(self) + mediafile.update(tags) try: mediafile.save() except (OSError, IOError, MutagenError) as exc: diff --git a/docs/changelog.rst b/docs/changelog.rst index da16ec940..41c9853ec 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -68,6 +68,8 @@ Fixes: this case. * :doc:`/plugins/convert`: Fix filename extensions when converting automatically. +* The ``write`` event allows plugins to change the tags that are + written to a media file. .. _discogs_client: https://github.com/discogs/discogs_client diff --git a/docs/dev/plugins.rst b/docs/dev/plugins.rst index 1f6ca80d3..768f982d8 100644 --- a/docs/dev/plugins.rst +++ b/docs/dev/plugins.rst @@ -143,11 +143,14 @@ currently available are: or album's part) is removed from the library (even when its file is not deleted from disk). -* *write*: called with an ``Item`` object just before a file's metadata is - written to disk (i.e., just before the file on disk is opened). Event - handlers may raise a ``library.FileOperationError`` exception to abort - the write operation. Beets will catch that exception, print an error - message and continue. +* *write*: called with an ``Item`` object, a ``path``, and a ``tags`` + dictionary just before a file's metadata is written to disk (i.e., + just before the file on disk is opened). Event handlers may change + the ``tags`` dictionary to customize the tags that are written to the + media file. Event handlers may also raise a + ``library.FileOperationError`` exception to abort the write + operation. Beets will catch that exception, print an error message + and continue. * *after_write*: called with an ``Item`` object after a file's metadata is written to disk (i.e., just after the file on disk is closed). diff --git a/test/test_plugins.py b/test/test_plugins.py index a48dd10ae..dd6b27942 100644 --- a/test/test_plugins.py +++ b/test/test_plugins.py @@ -14,16 +14,17 @@ from mock import patch from _common import unittest -from helper import TestHelper +import helper from beets import plugins from beets.library import Item from beets.dbcore import types +from beets.mediafile import MediaFile -class PluginTest(unittest.TestCase, TestHelper): +class TestHelper(helper.TestHelper): - def setUp(self): + def setup_plugin_loader(self): # FIXME the mocking code is horrific, but this is the lowest and # earliest level of the plugin mechanism we can hook into. self._plugin_loader_patch = patch('beets.plugins.load_plugins') @@ -35,9 +36,22 @@ class PluginTest(unittest.TestCase, TestHelper): load_plugins.side_effect = myload self.setup_beets() - def tearDown(self): + def teardown_plugin_loader(self): self._plugin_loader_patch.stop() self.unload_plugins() + + def register_plugin(self, plugin_class): + self._plugin_classes.add(plugin_class) + + +class ItemTypesTest(unittest.TestCase, TestHelper): + + def setUp(self): + self.setup_plugin_loader() + self.setup_beets() + + def tearDown(self): + self.teardown_plugin_loader() self.teardown_beets() def test_flex_field_type(self): @@ -64,8 +78,38 @@ class PluginTest(unittest.TestCase, TestHelper): out = self.run_with_output('ls', 'rating:3..5') self.assertNotIn('aaa', out) - def register_plugin(self, plugin_class): - self._plugin_classes.add(plugin_class) + +class ItemWriteTest(unittest.TestCase, TestHelper): + + def setUp(self): + self.setup_plugin_loader() + self.setup_beets() + + class EventListenerPlugin(plugins.BeetsPlugin): + pass + self.event_listener_plugin = EventListenerPlugin + self.register_plugin(EventListenerPlugin) + + def tearDown(self): + self.teardown_plugin_loader() + self.teardown_beets() + + def test_change_tags(self): + + def on_write(item=None, path=None, tags=None): + if tags['artist'] == 'XXX': + tags['artist'] = 'YYY' + + self.register_listener('write', on_write) + + item = self.add_item_fixture(artist='XXX') + item.write() + + mediafile = MediaFile(item.path) + self.assertEqual(mediafile.artist, 'YYY') + + def register_listener(self, event, func): + self.event_listener_plugin.register_listener(event, func) def suite(): From db391c8f20998d960cc7997e608b50714675143b Mon Sep 17 00:00:00 2001 From: Thomas Scholtes Date: Wed, 17 Sep 2014 12:17:20 +0200 Subject: [PATCH 2/2] zero: Only changes media file tags not database Uses the new API from the previous commit and fixes #963. There is a possible issue with backwards compatibility: Changes to the item in the 'write' event do not propagate to the tags anymore. But I'm not aware of other plugins that use the API in that way. --- beetsplug/zero.py | 8 ++++---- docs/changelog.rst | 2 ++ test/test_zero.py | 48 +++++++++++++++++++++++++++++----------------- 3 files changed, 36 insertions(+), 22 deletions(-) diff --git a/beetsplug/zero.py b/beetsplug/zero.py index b9768431c..8c65fe855 100644 --- a/beetsplug/zero.py +++ b/beetsplug/zero.py @@ -78,18 +78,18 @@ class ZeroPlugin(BeetsPlugin): return True return False - def write_event(self, item): + def write_event(self, item, path, tags): """Listen for write event.""" if not self.patterns: log.warn(u'[zero] no fields, nothing to do') return for field, patterns in self.patterns.items(): - if field not in item.keys(): + if field not in tags: log.error(u'[zero] no such field: {0}'.format(field)) continue - value = item[field] + value = tags[field] if self.match_patterns(value, patterns): log.debug(u'[zero] {0}: {1} -> None'.format(field, value)) - item[field] = None + tags[field] = None diff --git a/docs/changelog.rst b/docs/changelog.rst index 41c9853ec..223d816d3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -70,6 +70,8 @@ Fixes: automatically. * The ``write`` event allows plugins to change the tags that are written to a media file. +* :doc:`/plugins/zero`: Do not delete database values, only media file + tags. .. _discogs_client: https://github.com/discogs/discogs_client diff --git a/test/test_zero.py b/test/test_zero.py index e0a0ba943..38b854e45 100644 --- a/test/test_zero.py +++ b/test/test_zero.py @@ -18,37 +18,38 @@ class ZeroPluginTest(unittest.TestCase, TestHelper): self.unload_plugins() def test_no_patterns(self): - i = Item( - comments='test comment', - day=13, - month=3, - year=2012, - ) + tags = { + 'comments': 'test comment', + 'day': 13, + 'month': 3, + 'year': 2012, + } z = ZeroPlugin() z.debug = False z.fields = ['comments', 'month', 'day'] z.patterns = {'comments': ['.'], 'month': ['.'], 'day': ['.']} - z.write_event(i) - self.assertEqual(i.comments, '') - self.assertEqual(i.day, 0) - self.assertEqual(i.month, 0) - self.assertEqual(i.year, 2012) + z.write_event(None, None, tags) + self.assertEqual(tags['comments'], None) + self.assertEqual(tags['day'], None) + self.assertEqual(tags['month'], None) + self.assertEqual(tags['year'], 2012) def test_patterns(self): - i = Item( - comments='from lame collection, ripped by eac', - year=2012, - ) z = ZeroPlugin() z.debug = False z.fields = ['comments', 'year'] z.patterns = {'comments': 'eac lame'.split(), 'year': '2098 2099'.split()} - z.write_event(i) - self.assertEqual(i.comments, '') - self.assertEqual(i.year, 2012) + + tags = { + 'comments': 'from lame collection, ripped by eac', + 'year': 2012, + } + z.write_event(None, None, tags) + self.assertEqual(tags['comments'], None) + self.assertEqual(tags['year'], 2012) def test_delete_replaygain_tag(self): path = self.create_mediafile_fixture() @@ -70,6 +71,17 @@ class ZeroPluginTest(unittest.TestCase, TestHelper): self.assertIsNone(mediafile.rg_track_peak) self.assertIsNone(mediafile.rg_track_gain) + def test_do_not_change_database(self): + item = self.add_item_fixture(year=2000) + mediafile = MediaFile(item.path) + + config['zero'] = {'fields': ['year']} + self.load_plugins('zero') + + item.write() + self.assertEqual(item['year'], 2000) + self.assertIsNone(mediafile.year) + def suite(): return unittest.TestLoader().loadTestsFromName(__name__)