Merge branch 'extendable-mediafile'

Conflicts:
	beets/library.py
	docs/dev/plugins.rst
This commit is contained in:
Thomas Scholtes 2014-04-07 23:49:06 +02:00
commit 55e5381bbd
10 changed files with 352 additions and 89 deletions

View file

@ -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)

View file

@ -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.

View file

@ -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

View file

@ -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:

View file

@ -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')

View file

@ -8,3 +8,4 @@ in hacking beets itself or creating plugins for it.
plugins
api
media_file

21
docs/dev/media_file.rst Normal file
View file

@ -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:

View file

@ -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
^^^^^^^^^^^^^^^^^^^^^^^^^^

View file

@ -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

View file

@ -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__)