mirror of
https://github.com/beetbox/beets.git
synced 2026-02-26 09:11:32 +01:00
Merge branch 'extendable-mediafile'
Conflicts: beets/library.py docs/dev/plugins.rst
This commit is contained in:
commit
55e5381bbd
10 changed files with 352 additions and 89 deletions
160
beets/library.py
160
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
21
docs/dev/media_file.rst
Normal 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:
|
||||
|
|
@ -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
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue