Skip to content
164 changes: 86 additions & 78 deletions beets/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,72 +122,71 @@ def parse(self, string):
# - Is the field writable?
# - Does the field reflect an attribute of a MediaFile?
ITEM_FIELDS = [
('id', types.Id(), False, False),
('path', PathType(), False, False),
('album_id', types.Integer(), False, 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),

('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),
('id', types.Id()),
('path', PathType()),
('album_id', types.Integer()),

('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()),
('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.
Expand Down Expand Up @@ -328,11 +327,18 @@ def add(self, lib=None):


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()
Expand All @@ -357,7 +363,7 @@ def __setitem__(self, key, value):
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)
Expand All @@ -383,8 +389,11 @@ def get_album(self):
# 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.
"""
Expand All @@ -393,20 +402,19 @@ def read(self, read_path=None):
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:
Expand All @@ -417,19 +425,19 @@ def read(self, read_path=None):
def write(self):
"""Write the item's metadata to the associated file.

Updates the mediafile with properties from itself.

Can raise either a `ReadError` or a `WriteError`.
"""
try:
f = MediaFile(syspath(self.path))
mediafile = MediaFile(syspath(self.path))
except (OSError, IOError) as exc:
raise ReadError(self.path, exc)

plugins.send('write', item=self)

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)

Expand Down
66 changes: 61 additions & 5 deletions beets/mediafile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1256,6 +1258,60 @@ def delete(self):
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.

Expand Down
14 changes: 14 additions & 0 deletions beets/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,20 @@ def track_for_id(self, track_id):
"""
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

Expand Down
4 changes: 2 additions & 2 deletions beets/ui/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -923,7 +923,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:
Expand Down Expand Up @@ -1250,7 +1250,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:
Expand Down
4 changes: 3 additions & 1 deletion beetsplug/bpd/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@
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
Expand Down Expand Up @@ -67,6 +68,7 @@
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')
Expand Down
1 change: 1 addition & 0 deletions docs/dev/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ in hacking beets itself or creating plugins for it.

plugins
api
media_file
21 changes: 21 additions & 0 deletions docs/dev/media_file.rst
Original file line number Diff line number Diff line change
@@ -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:
Loading