diff --git a/beets/library.py b/beets/library.py index fb205b4be..4b4563d60 100644 --- a/beets/library.py +++ b/beets/library.py @@ -123,72 +123,71 @@ class PathType(types.Type): # - Is the field writable? # - Does the field reflect an attribute of a MediaFile? ITEM_FIELDS = [ - ('id', types.Id(True), False, False), - ('path', PathType(), False, False), - ('album_id', types.Id(False), False, False), + ('id', types.Id(True)), + ('path', PathType()), + ('album_id', types.Id(False)), - ('title', types.String(), True, True), - ('artist', types.String(), True, True), - ('artist_sort', types.String(), True, True), - ('artist_credit', types.String(), True, True), - ('album', types.String(), True, True), - ('albumartist', types.String(), True, True), - ('albumartist_sort', types.String(), True, True), - ('albumartist_credit', types.String(), True, True), - ('genre', types.String(), True, True), - ('composer', types.String(), True, True), - ('grouping', types.String(), True, True), - ('year', types.PaddedInt(4), True, True), - ('month', types.PaddedInt(2), True, True), - ('day', types.PaddedInt(2), True, True), - ('track', types.PaddedInt(2), True, True), - ('tracktotal', types.PaddedInt(2), True, True), - ('disc', types.PaddedInt(2), True, True), - ('disctotal', types.PaddedInt(2), True, True), - ('lyrics', types.String(), True, True), - ('comments', types.String(), True, True), - ('bpm', types.Integer(), True, True), - ('comp', types.Boolean(), True, True), - ('mb_trackid', types.String(), True, True), - ('mb_albumid', types.String(), True, True), - ('mb_artistid', types.String(), True, True), - ('mb_albumartistid', types.String(), True, True), - ('albumtype', types.String(), True, True), - ('label', types.String(), True, True), - ('acoustid_fingerprint', types.String(), True, True), - ('acoustid_id', types.String(), True, True), - ('mb_releasegroupid', types.String(), True, True), - ('asin', types.String(), True, True), - ('catalognum', types.String(), True, True), - ('script', types.String(), True, True), - ('language', types.String(), True, True), - ('country', types.String(), True, True), - ('albumstatus', types.String(), True, True), - ('media', types.String(), True, True), - ('albumdisambig', types.String(), True, True), - ('disctitle', types.String(), True, True), - ('encoder', types.String(), True, True), - ('rg_track_gain', types.Float(), True, True), - ('rg_track_peak', types.Float(), True, True), - ('rg_album_gain', types.Float(), True, True), - ('rg_album_peak', types.Float(), True, True), - ('original_year', types.PaddedInt(4), True, True), - ('original_month', types.PaddedInt(2), True, True), - ('original_day', types.PaddedInt(2), True, True), + ('title', types.String()), + ('artist', types.String()), + ('artist_sort', types.String()), + ('artist_credit', types.String()), + ('album', types.String()), + ('albumartist', types.String()), + ('albumartist_sort', types.String()), + ('albumartist_credit', types.String()), + ('genre', types.String()), + ('composer', types.String()), + ('grouping', types.String()), + ('year', types.PaddedInt(4)), + ('month', types.PaddedInt(2)), + ('day', types.PaddedInt(2)), + ('track', types.PaddedInt(2)), + ('tracktotal', types.PaddedInt(2)), + ('disc', types.PaddedInt(2)), + ('disctotal', types.PaddedInt(2)), + ('lyrics', types.String()), + ('comments', types.String()), + ('bpm', types.Integer()), + ('comp', types.Boolean()), + ('mb_trackid', types.String()), + ('mb_albumid', types.String()), + ('mb_artistid', types.String()), + ('mb_albumartistid', types.String()), + ('albumtype', types.String()), + ('label', types.String()), + ('acoustid_fingerprint', types.String()), + ('acoustid_id', types.String()), + ('mb_releasegroupid', types.String()), + ('asin', types.String()), + ('catalognum', types.String()), + ('script', types.String()), + ('language', types.String()), + ('country', types.String()), + ('albumstatus', types.String()), + ('media', types.String()), + ('albumdisambig', types.String()), + ('disctitle', types.String()), + ('encoder', types.String()), + ('rg_track_gain', types.Float()), + ('rg_track_peak', types.Float()), + ('rg_album_gain', types.Float()), + ('rg_album_peak', types.Float()), + ('original_year', types.PaddedInt(4)), + ('original_month', types.PaddedInt(2)), + ('original_day', types.PaddedInt(2)), - ('length', types.Float(), False, True), - ('bitrate', types.ScaledInt(1000, u'kbps'), False, True), - ('format', types.String(), False, True), - ('samplerate', types.ScaledInt(1000, u'kHz'), False, True), - ('bitdepth', types.Integer(), False, True), - ('channels', types.Integer(), False, True), - ('mtime', DateType(), False, False), - ('added', DateType(), False, False), + ('length', types.Float()), + ('bitrate', types.ScaledInt(1000, u'kbps')), + ('format', types.String()), + ('samplerate', types.ScaledInt(1000, u'kHz')), + ('bitdepth', types.Integer()), + ('channels', types.Integer()), + ('mtime', DateType()), + ('added', DateType()), ] -ITEM_KEYS_WRITABLE = [f[0] for f in ITEM_FIELDS if f[3] and f[2]] -ITEM_KEYS_META = [f[0] for f in ITEM_FIELDS if f[3]] ITEM_KEYS = [f[0] for f in ITEM_FIELDS] + # Database fields for the "albums" table. # The third entry in each tuple indicates whether the field reflects an # identically-named field in the items table. @@ -329,11 +328,18 @@ class LibModel(dbcore.Model): class Item(LibModel): - _fields = dict((name, typ) for (name, typ, _, _) in ITEM_FIELDS) + _fields = dict((name, typ) for (name, typ) in ITEM_FIELDS) _table = 'items' _flex_table = 'item_attributes' _search_fields = ITEM_DEFAULT_FIELDS + media_fields = set(MediaFile.readable_fields()).intersection(ITEM_KEYS) + """Set of property names to read from ``MediaFile``. + + ``item.read()`` will read all properties in this set from + ``MediaFile`` and set them on the item. + """ + @classmethod def _getters(cls): return plugins.item_field_getters() @@ -358,7 +364,7 @@ class Item(LibModel): elif isinstance(value, buffer): value = str(value) - if key in ITEM_KEYS_WRITABLE: + if key in MediaFile.fields(): self.mtime = 0 # Reset mtime on dirty. super(Item, self).__setitem__(key, value) @@ -384,8 +390,11 @@ class Item(LibModel): # Interaction with file metadata. def read(self, read_path=None): - """Read the metadata from the associated file. If read_path is - specified, read metadata from that file instead. + """Read the metadata from the associated file. + + If ``read_path`` is specified, read metadata from that file + instead. Updates all the properties in ``Item.media_fields`` + from the media file. Raises a `ReadError` if the file could not be read. """ @@ -394,20 +403,19 @@ class Item(LibModel): else: read_path = normpath(read_path) try: - f = MediaFile(syspath(read_path)) + mediafile = MediaFile(syspath(read_path)) except (OSError, IOError) as exc: raise ReadError(read_path, exc) - for key in ITEM_KEYS_META: - value = getattr(f, key) + for key in list(self.media_fields): + value = getattr(mediafile, key) if isinstance(value, (int, long)): - # Filter values wider than 64 bits (in signed - # representation). SQLite cannot store them. - # py26: Post transition, we can use: + # Filter values wider than 64 bits (in signed representation). + # SQLite cannot store them. py26: Post transition, we can use: # value.bit_length() > 63 if abs(value) >= 2 ** 63: value = 0 - setattr(self, key, value) + self[key] = value # Database's mtime should now reflect the on-disk value. if read_path == self.path: @@ -418,7 +426,7 @@ class Item(LibModel): def write(self, path=None): """Write the item's metadata to a media file. - ``path`` defaults to the item's path property. + Updates the mediafile with properties from itself. Can raise either a `ReadError` or a `WriteError`. """ @@ -427,16 +435,14 @@ class Item(LibModel): else: path = normpath(path) try: - f = MediaFile(syspath(path)) + mediafile = MediaFile(path) except (OSError, IOError) as exc: raise ReadError(self.path, exc) plugins.send('write', item=self, path=path) - for key in ITEM_KEYS_WRITABLE: - setattr(f, key, self[key]) try: - f.save(id3v23=beets.config['id3v23'].get(bool)) + mediafile.update(self, id3v23=beets.config['id3v23'].get(bool)) except (OSError, IOError, MutagenError) as exc: raise WriteError(self.path, exc) diff --git a/beets/mediafile.py b/beets/mediafile.py index 301e0f370..c5e5efefa 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -913,12 +913,14 @@ class MediaField(object): def __init__(self, *styles, **kwargs): """Creates a new MediaField. - - `styles`: `StorageStyle` instances that describe the strategy - for reading and writing the field in particular formats. - There must be at least one style for each possible file - format. + - `styles`: `StorageStyle` instances that describe the strategy + for reading and writing the field in particular formats. + There must be at least one style for each possible file + format. + - `out_type`: the type of the value that should be returned when - getting this property. + getting this property. + """ self.out_type = kwargs.get('out_type', unicode) self._styles = styles @@ -1256,6 +1258,60 @@ class MediaFile(object): for tag in self.mgfile.keys(): del self.mgfile[tag] + @classmethod + def fields(cls): + """Yield the names of all properties that are MediaFields. + """ + for property, descriptor in cls.__dict__.items(): + if isinstance(descriptor, MediaField): + yield property + + @classmethod + def readable_fields(cls): + """Yield the elements of ``fields()`` and all additional + properties retrieved from the file + """ + for property in cls.fields(): + yield property + for property in ['length', 'samplerate', 'bitdepth', 'bitrate', + 'channels', 'format']: + yield property + + @classmethod + def add_field(cls, name, descriptor): + """Add a field to store custom tags. + + ``name`` is the name of the property the field is accessed + through. It must not already exist for the class. If the name + coincides with the name of a property of ``Item`` it will be set + from the item in ``item.write()``. + + ``descriptor`` must be an instance of ``MediaField``. + """ + if not isinstance(descriptor, MediaField): + raise ValueError( + u'{0} must be an instance of MediaField'.format(descriptor)) + if name in cls.__dict__: + raise ValueError( + u'property "{0}" already exists on MediaField'.format(name)) + setattr(cls, name, descriptor) + + def update(self, dict, id3v23=False): + """Update tags from the dictionary and write them to the file. + + For any key in ``dict`` that is also a field to store tags the + method retrieves the corresponding value from ``dict`` and + updates the ``MediaFile``. The changes are then written to the + disk. + + By default, MP3 files are saved with ID3v2.4 tags. You can use + the older ID3v2.3 standard by specifying the `id3v23` option. + """ + for field in self.fields(): + if field in dict: + setattr(self, field, dict[field]) + self.save(id3v23) + # Field definitions. diff --git a/beets/plugins.py b/beets/plugins.py index b6fb157ca..18bc3290c 100755 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -20,6 +20,7 @@ from collections import defaultdict import inspect import beets +from beets import mediafile PLUGIN_NAMESPACE = 'beetsplug' @@ -97,6 +98,20 @@ class BeetsPlugin(object): """ return None + def add_media_field(self, name, descriptor): + """Add a field that is synchronized between media files and items. + + When a media field is added ``item.write()`` will set the name + property of the item's MediaFile to ``item[name]`` and save the + changes. Similarly ``item.read()`` will set ``item[name]`` to + the value of the name property of the media file. + + ``descriptor`` must be an instance of ``mediafile.MediaField``. + """ + # Defer impor to prevent circular dependency + from beets import library + mediafile.MediaFile.add_field(name, descriptor) + library.Item.media_fields.add(name) listeners = None diff --git a/beets/ui/commands.py b/beets/ui/commands.py index c466eb56a..5aadfdca4 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -919,7 +919,7 @@ def update_items(lib, query, album, move, pretend): # Check for and display changes. changed = ui.show_model_changes(item, - fields=library.ITEM_KEYS_META) + fields=library.Item.media_fields) # Save changes. if not pretend: @@ -1246,7 +1246,7 @@ def write_items(lib, query, pretend): # Check for and display changes. changed = ui.show_model_changes(item, clean_item, - library.ITEM_KEYS_WRITABLE, + MediaFile.fields(), always=True) if changed and not pretend: try: diff --git a/beetsplug/bpd/__init__.py b/beetsplug/bpd/__init__.py index 9306c1645..13eb6fb97 100644 --- a/beetsplug/bpd/__init__.py +++ b/beetsplug/bpd/__init__.py @@ -30,8 +30,9 @@ from beets.plugins import BeetsPlugin import beets.ui from beets import vfs from beets.util import bluelet -from beets.library import ITEM_KEYS_WRITABLE +from beets.library import ITEM_KEYS from beets import dbcore +from beets.mediafile import MediaFile PROTOCOL_VERSION = '0.13.0' BUFSIZE = 1024 @@ -67,6 +68,7 @@ SAFE_COMMANDS = ( u'close', u'commands', u'notcommands', u'password', u'ping', ) +ITEM_KEYS_WRITABLE = set(MediaFile.fields()).intersection(ITEM_KEYS) # Loggers. log = logging.getLogger('beets.bpd') diff --git a/docs/dev/index.rst b/docs/dev/index.rst index 2579ecbf4..82651a781 100644 --- a/docs/dev/index.rst +++ b/docs/dev/index.rst @@ -8,3 +8,4 @@ in hacking beets itself or creating plugins for it. plugins api + media_file diff --git a/docs/dev/media_file.rst b/docs/dev/media_file.rst new file mode 100644 index 000000000..c703377d8 --- /dev/null +++ b/docs/dev/media_file.rst @@ -0,0 +1,21 @@ +.. _mediafile: + +MediaFile +--------- + +.. currentmodule:: beets.mediafile + +.. autoclass:: MediaFile + + .. automethod:: __init__ + .. automethod:: fields + .. automethod:: readable_fields + .. automethod:: save + .. automethod:: update + +.. autoclass:: MediaField + + .. automethod:: __init__ + +.. autoclass:: StorageStyle + :members: diff --git a/docs/dev/plugins.rst b/docs/dev/plugins.rst index 78b716d0e..cda1ca29e 100644 --- a/docs/dev/plugins.rst +++ b/docs/dev/plugins.rst @@ -297,6 +297,40 @@ This field works for *item* templates. Similarly, you can register *album* template fields by adding a function accepting an ``Album`` argument to the ``album_template_fields`` dict. +Extend MediaFile +^^^^^^^^^^^^^^^^ + +:ref:`MediaFile` is the file tag abstraction layer that beets uses to make +cross-format metadata manipulation simple. Plugins can add fields to MediaFile +to extend the kinds of metadata that they can easily manage. + +The ``MediaFile`` class uses ``MediaField`` descriptors to provide +access to file tags. Have a look at the ``beets.mediafile`` source code +to learn how to use this descriptor class. If you have created a +descriptor you can add it through your plugins ``add_media_field()`` +method. + +.. automethod:: beets.plugins.BeetsPlugin.add_media_field + + +Here's an example plugin that provides a meaningless new field "foo":: + + class FooPlugin(BeetsPlugin): + def __init__(self): + field = mediafile.MediaField( + mediafile.MP3DescStorageStyle(u'foo') + mediafile.StorageStyle(u'foo') + ) + self.add_media_field('foo', field) + + FooPlugin() + item = Item.from_path('/path/to/foo/tag.mp3') + assert item['foo'] == 'spam' + + item['foo'] == 'ham' + item.write() + # The "foo" tag of the file is now "ham" + Add Import Pipeline Stages ^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/test/helper.py b/test/helper.py index eae9f86b6..2efb6d9a1 100644 --- a/test/helper.py +++ b/test/helper.py @@ -95,7 +95,7 @@ class TestHelper(object): self.lib = Library(self.config['library'].as_filename(), self.libdir) - + def teardown_beets(self): del os.environ['BEETSDIR'] # FIXME somehow close all open fd to the ilbrary diff --git a/test/test_mediafile.py b/test/test_mediafile.py index 19d2ceda8..bcf80ccbe 100644 --- a/test/test_mediafile.py +++ b/test/test_mediafile.py @@ -23,7 +23,11 @@ import time import _common from _common import unittest -from beets.mediafile import MediaFile, Image +from beets.mediafile import MediaFile, MediaField, Image, \ + MP3StorageStyle, StorageStyle, \ + MP4StorageStyle, ASFStorageStyle +from beets.library import Item +from beets.plugins import BeetsPlugin class ArtTestMixin(object): @@ -161,11 +165,11 @@ class ExtendedImageStructureTestMixin(ImageStructureTestMixin): desc='the composer', type=Image.TYPES.composer) -# TODO include this in ReadWriteTestBase if implemented class LazySaveTestMixin(object): """Mediafile should only write changes when tags have changed """ + @unittest.skip('not yet implemented') def test_unmodified(self): mediafile = self._mediafile_fixture('full') mtime = self._set_past_mtime(mediafile.path) @@ -174,6 +178,7 @@ class LazySaveTestMixin(object): mediafile.save() self.assertEqual(os.stat(mediafile.path).st_mtime, mtime) + @unittest.skip('not yet implemented') def test_same_tag_value(self): mediafile = self._mediafile_fixture('full') mtime = self._set_past_mtime(mediafile.path) @@ -183,6 +188,15 @@ class LazySaveTestMixin(object): mediafile.save() self.assertEqual(os.stat(mediafile.path).st_mtime, mtime) + def test_update_same_tag_value(self): + mediafile = self._mediafile_fixture('full') + mtime = self._set_past_mtime(mediafile.path) + self.assertEqual(os.stat(mediafile.path).st_mtime, mtime) + + mediafile.update({'title': mediafile.title}) + self.assertEqual(os.stat(mediafile.path).st_mtime, mtime) + + @unittest.skip('not yet implemented') def test_tag_value_change(self): mediafile = self._mediafile_fixture('full') mtime = self._set_past_mtime(mediafile.path) @@ -193,6 +207,14 @@ class LazySaveTestMixin(object): mediafile.save() self.assertNotEqual(os.stat(mediafile.path).st_mtime, mtime) + def test_update_changed_tag_value(self): + mediafile = self._mediafile_fixture('full') + mtime = self._set_past_mtime(mediafile.path) + self.assertEqual(os.stat(mediafile.path).st_mtime, mtime) + + mediafile.update({'title': mediafile.title, 'album': 'another'}) + self.assertNotEqual(os.stat(mediafile.path).st_mtime, mtime) + def _set_past_mtime(self, path): mtime = round(time.time() - 10000) os.utime(path, (mtime, mtime)) @@ -233,7 +255,68 @@ class GenreListTestMixin(object): self.assertItemsEqual(mediafile.genres, [u'the genre', u'another']) -class ReadWriteTestBase(ArtTestMixin, GenreListTestMixin): +field_extension = MediaField( + MP3StorageStyle('TKEY'), + MP4StorageStyle('----:com.apple.iTunes:initialkey'), + StorageStyle('INITIALKEY'), + ASFStorageStyle('INITIALKEY'), +) +class ExtendedFieldTestMixin(object): + + def test_extended_field_write(self): + plugin = BeetsPlugin() + plugin.add_media_field('initialkey', field_extension) + + mediafile = self._mediafile_fixture('empty') + mediafile.initialkey = 'F#' + mediafile.save() + + mediafile = MediaFile(mediafile.path) + self.assertEqual(mediafile.initialkey, 'F#') + delattr(MediaFile, 'initialkey') + Item.media_fields.remove('initialkey') + + def test_write_extended_tag_from_item(self): + plugin = BeetsPlugin() + plugin.add_media_field('initialkey', field_extension) + + mediafile = self._mediafile_fixture('empty') + self.assertEqual(mediafile.initialkey, '') + + item = Item(path=mediafile.path, initialkey='Gb') + item.write() + mediafile = MediaFile(mediafile.path) + self.assertEqual(mediafile.initialkey, 'Gb') + + delattr(MediaFile, 'initialkey') + Item.media_fields.remove('initialkey') + + def test_read_flexible_attribute_from_file(self): + plugin = BeetsPlugin() + plugin.add_media_field('initialkey', field_extension) + + mediafile = self._mediafile_fixture('empty') + mediafile.update({'initialkey': 'F#'}) + + item = Item.from_path(mediafile.path) + self.assertEqual(item['initialkey'], 'F#') + + delattr(MediaFile, 'initialkey') + Item.media_fields.remove('initialkey') + + def test_invalid_descriptor(self): + with self.assertRaises(ValueError) as cm: + MediaFile.add_field('somekey', True) + self.assertIn('must be an instance of MediaField', str(cm.exception)) + + def test_overwrite_property(self): + with self.assertRaises(ValueError) as cm: + MediaFile.add_field('artist', MediaField()) + self.assertIn('property "artist" already exists', str(cm.exception)) + + +class ReadWriteTestBase(ArtTestMixin, GenreListTestMixin, + ExtendedFieldTestMixin): """Test writing and reading tags. Subclasses must set ``extension`` and ``audio_properties``. """ @@ -353,6 +436,15 @@ class ReadWriteTestBase(ArtTestMixin, GenreListTestMixin): mediafile = MediaFile(mediafile.path) self.assertTags(mediafile, tags) + def test_update_empty(self): + mediafile = self._mediafile_fixture('empty') + tags = self._generate_tags() + + mediafile.update(tags) + + mediafile = MediaFile(mediafile.path) + self.assertTags(mediafile, tags) + def test_overwrite_full(self): mediafile = self._mediafile_fixture('full') tags = self._generate_tags() @@ -369,6 +461,17 @@ class ReadWriteTestBase(ArtTestMixin, GenreListTestMixin): mediafile = MediaFile(mediafile.path) self.assertTags(mediafile, tags) + def test_update_full(self): + mediafile = self._mediafile_fixture('full') + tags = self._generate_tags() + + mediafile.update(tags) + # Make sure the tags are already set when writing a second time + mediafile.update(tags) + + mediafile = MediaFile(mediafile.path) + self.assertTags(mediafile, tags) + def test_write_date_components(self): mediafile = self._mediafile_fixture('full') mediafile.year = 2001 @@ -692,6 +795,31 @@ class OpusTest(ReadWriteTestBase, unittest.TestCase): } +class MediaFieldTest(unittest.TestCase): + + def test_properties_from_fields(self): + path = os.path.join(_common.RSRC, 'full.mp3') + mediafile = MediaFile(path) + for field in MediaFile.fields(): + self.assertTrue(hasattr(mediafile, field)) + + def test_properties_from_readable_fields(self): + path = os.path.join(_common.RSRC, 'full.mp3') + mediafile = MediaFile(path) + for field in MediaFile.readable_fields(): + self.assertTrue(hasattr(mediafile, field)) + + def test_known_fields(self): + fields = ReadWriteTestBase.empty_tags.keys() + fields.extend(('encoder', 'images', 'genres', 'albumtype')) + self.assertItemsEqual(MediaFile.fields(), fields) + + def test_fields_in_readable_fields(self): + readable = MediaFile.readable_fields() + for field in MediaFile.fields(): + self.assertIn(field, readable) + + def suite(): return unittest.TestLoader().loadTestsFromName(__name__)