Skip to content

Commit 66f952b

Browse files
committed
Merge pull request #965 from geigerzaehler/write-hook-mutate
Zero plugin can modify tags without changing the item
2 parents a38a6b2 + db391c8 commit 66f952b

File tree

6 files changed

+99
-35
lines changed

6 files changed

+99
-35
lines changed

beets/library.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -473,15 +473,16 @@ def write(self, path=None):
473473
else:
474474
path = normpath(path)
475475

476-
plugins.send('write', item=self, path=path)
476+
tags = dict(self)
477+
plugins.send('write', item=self, path=path, tags=tags)
477478

478479
try:
479480
mediafile = MediaFile(syspath(path),
480481
id3v23=beets.config['id3v23'].get(bool))
481482
except (OSError, IOError, UnreadableFileError) as exc:
482483
raise ReadError(self.path, exc)
483484

484-
mediafile.update(self)
485+
mediafile.update(tags)
485486
try:
486487
mediafile.save()
487488
except (OSError, IOError, MutagenError) as exc:

beetsplug/zero.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,18 +78,18 @@ def match_patterns(cls, field, patterns):
7878
return True
7979
return False
8080

81-
def write_event(self, item):
81+
def write_event(self, item, path, tags):
8282
"""Listen for write event."""
8383
if not self.patterns:
8484
log.warn(u'[zero] no fields, nothing to do')
8585
return
8686

8787
for field, patterns in self.patterns.items():
88-
if field not in item.keys():
88+
if field not in tags:
8989
log.error(u'[zero] no such field: {0}'.format(field))
9090
continue
9191

92-
value = item[field]
92+
value = tags[field]
9393
if self.match_patterns(value, patterns):
9494
log.debug(u'[zero] {0}: {1} -> None'.format(field, value))
95-
item[field] = None
95+
tags[field] = None

docs/changelog.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ Fixes:
6868
this case.
6969
* :doc:`/plugins/convert`: Fix filename extensions when converting
7070
automatically.
71+
* The ``write`` event allows plugins to change the tags that are
72+
written to a media file.
73+
* :doc:`/plugins/zero`: Do not delete database values, only media file
74+
tags.
7175

7276
.. _discogs_client: https://github.com/discogs/discogs_client
7377

docs/dev/plugins.rst

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -143,11 +143,14 @@ currently available are:
143143
or album's part) is removed from the library (even when its file is not
144144
deleted from disk).
145145

146-
* *write*: called with an ``Item`` object just before a file's metadata is
147-
written to disk (i.e., just before the file on disk is opened). Event
148-
handlers may raise a ``library.FileOperationError`` exception to abort
149-
the write operation. Beets will catch that exception, print an error
150-
message and continue.
146+
* *write*: called with an ``Item`` object, a ``path``, and a ``tags``
147+
dictionary just before a file's metadata is written to disk (i.e.,
148+
just before the file on disk is opened). Event handlers may change
149+
the ``tags`` dictionary to customize the tags that are written to the
150+
media file. Event handlers may also raise a
151+
``library.FileOperationError`` exception to abort the write
152+
operation. Beets will catch that exception, print an error message
153+
and continue.
151154

152155
* *after_write*: called with an ``Item`` object after a file's metadata is
153156
written to disk (i.e., just after the file on disk is closed).

test/test_plugins.py

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,17 @@
1414

1515
from mock import patch
1616
from _common import unittest
17-
from helper import TestHelper
17+
import helper
1818

1919
from beets import plugins
2020
from beets.library import Item
2121
from beets.dbcore import types
22+
from beets.mediafile import MediaFile
2223

2324

24-
class PluginTest(unittest.TestCase, TestHelper):
25+
class TestHelper(helper.TestHelper):
2526

26-
def setUp(self):
27+
def setup_plugin_loader(self):
2728
# FIXME the mocking code is horrific, but this is the lowest and
2829
# earliest level of the plugin mechanism we can hook into.
2930
self._plugin_loader_patch = patch('beets.plugins.load_plugins')
@@ -35,9 +36,22 @@ def myload(names=()):
3536
load_plugins.side_effect = myload
3637
self.setup_beets()
3738

38-
def tearDown(self):
39+
def teardown_plugin_loader(self):
3940
self._plugin_loader_patch.stop()
4041
self.unload_plugins()
42+
43+
def register_plugin(self, plugin_class):
44+
self._plugin_classes.add(plugin_class)
45+
46+
47+
class ItemTypesTest(unittest.TestCase, TestHelper):
48+
49+
def setUp(self):
50+
self.setup_plugin_loader()
51+
self.setup_beets()
52+
53+
def tearDown(self):
54+
self.teardown_plugin_loader()
4155
self.teardown_beets()
4256

4357
def test_flex_field_type(self):
@@ -64,8 +78,38 @@ class RatingPlugin(plugins.BeetsPlugin):
6478
out = self.run_with_output('ls', 'rating:3..5')
6579
self.assertNotIn('aaa', out)
6680

67-
def register_plugin(self, plugin_class):
68-
self._plugin_classes.add(plugin_class)
81+
82+
class ItemWriteTest(unittest.TestCase, TestHelper):
83+
84+
def setUp(self):
85+
self.setup_plugin_loader()
86+
self.setup_beets()
87+
88+
class EventListenerPlugin(plugins.BeetsPlugin):
89+
pass
90+
self.event_listener_plugin = EventListenerPlugin
91+
self.register_plugin(EventListenerPlugin)
92+
93+
def tearDown(self):
94+
self.teardown_plugin_loader()
95+
self.teardown_beets()
96+
97+
def test_change_tags(self):
98+
99+
def on_write(item=None, path=None, tags=None):
100+
if tags['artist'] == 'XXX':
101+
tags['artist'] = 'YYY'
102+
103+
self.register_listener('write', on_write)
104+
105+
item = self.add_item_fixture(artist='XXX')
106+
item.write()
107+
108+
mediafile = MediaFile(item.path)
109+
self.assertEqual(mediafile.artist, 'YYY')
110+
111+
def register_listener(self, event, func):
112+
self.event_listener_plugin.register_listener(event, func)
69113

70114

71115
def suite():

test/test_zero.py

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,37 +18,38 @@ def tearDown(self):
1818
self.unload_plugins()
1919

2020
def test_no_patterns(self):
21-
i = Item(
22-
comments='test comment',
23-
day=13,
24-
month=3,
25-
year=2012,
26-
)
21+
tags = {
22+
'comments': 'test comment',
23+
'day': 13,
24+
'month': 3,
25+
'year': 2012,
26+
}
2727
z = ZeroPlugin()
2828
z.debug = False
2929
z.fields = ['comments', 'month', 'day']
3030
z.patterns = {'comments': ['.'],
3131
'month': ['.'],
3232
'day': ['.']}
33-
z.write_event(i)
34-
self.assertEqual(i.comments, '')
35-
self.assertEqual(i.day, 0)
36-
self.assertEqual(i.month, 0)
37-
self.assertEqual(i.year, 2012)
33+
z.write_event(None, None, tags)
34+
self.assertEqual(tags['comments'], None)
35+
self.assertEqual(tags['day'], None)
36+
self.assertEqual(tags['month'], None)
37+
self.assertEqual(tags['year'], 2012)
3838

3939
def test_patterns(self):
40-
i = Item(
41-
comments='from lame collection, ripped by eac',
42-
year=2012,
43-
)
4440
z = ZeroPlugin()
4541
z.debug = False
4642
z.fields = ['comments', 'year']
4743
z.patterns = {'comments': 'eac lame'.split(),
4844
'year': '2098 2099'.split()}
49-
z.write_event(i)
50-
self.assertEqual(i.comments, '')
51-
self.assertEqual(i.year, 2012)
45+
46+
tags = {
47+
'comments': 'from lame collection, ripped by eac',
48+
'year': 2012,
49+
}
50+
z.write_event(None, None, tags)
51+
self.assertEqual(tags['comments'], None)
52+
self.assertEqual(tags['year'], 2012)
5253

5354
def test_delete_replaygain_tag(self):
5455
path = self.create_mediafile_fixture()
@@ -70,6 +71,17 @@ def test_delete_replaygain_tag(self):
7071
self.assertIsNone(mediafile.rg_track_peak)
7172
self.assertIsNone(mediafile.rg_track_gain)
7273

74+
def test_do_not_change_database(self):
75+
item = self.add_item_fixture(year=2000)
76+
mediafile = MediaFile(item.path)
77+
78+
config['zero'] = {'fields': ['year']}
79+
self.load_plugins('zero')
80+
81+
item.write()
82+
self.assertEqual(item['year'], 2000)
83+
self.assertIsNone(mediafile.year)
84+
7385

7486
def suite():
7587
return unittest.TestLoader().loadTestsFromName(__name__)

0 commit comments

Comments
 (0)