mirror of
https://github.com/beetbox/beets.git
synced 2026-01-01 05:23:05 +01:00
Merge branch 'flextypes'
This commit is contained in:
commit
ffc75c333d
15 changed files with 377 additions and 6 deletions
|
|
@ -737,7 +737,7 @@ class Database(object):
|
|||
id INTEGER PRIMARY KEY,
|
||||
entity_id INTEGER,
|
||||
key TEXT,
|
||||
value TEXT,
|
||||
value NONE,
|
||||
UNIQUE(entity_id, key) ON CONFLICT REPLACE);
|
||||
CREATE INDEX IF NOT EXISTS {0}_by_entity
|
||||
ON {0} (entity_id);
|
||||
|
|
|
|||
|
|
@ -208,7 +208,9 @@ class NumericQuery(FieldQuery):
|
|||
self.rangemax = self._convert(parts[1])
|
||||
|
||||
def match(self, item):
|
||||
value = getattr(item, self.field)
|
||||
if self.field not in item:
|
||||
return False
|
||||
value = item[self.field]
|
||||
if isinstance(value, basestring):
|
||||
value = self._convert(value)
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@ class PathQuery(dbcore.FieldQuery):
|
|||
|
||||
|
||||
class DateType(types.Type):
|
||||
# TODO representation should be `datetime` object
|
||||
# TODO distinguish beetween date and time types
|
||||
sql = u'REAL'
|
||||
query = dbcore.query.DateQuery
|
||||
null = 0.0
|
||||
|
|
|
|||
|
|
@ -31,6 +31,14 @@ LASTFM_KEY = '2dc3914abf35f0d9c92d97d8f8e42b43'
|
|||
log = logging.getLogger('beets')
|
||||
|
||||
|
||||
class PluginConflictException(Exception):
|
||||
"""Indicates that the services provided by one plugin conflict with
|
||||
those of another.
|
||||
|
||||
For example two plugins may define different types for flexible fields.
|
||||
"""
|
||||
|
||||
|
||||
# Managing the plugins themselves.
|
||||
|
||||
class BeetsPlugin(object):
|
||||
|
|
@ -136,7 +144,7 @@ class BeetsPlugin(object):
|
|||
|
||||
>>> @MyPlugin.listen("imported")
|
||||
>>> def importListener(**kwargs):
|
||||
>>> pass
|
||||
... pass
|
||||
"""
|
||||
def helper(func):
|
||||
if cls.listeners is None:
|
||||
|
|
@ -247,6 +255,22 @@ def queries():
|
|||
return out
|
||||
|
||||
|
||||
def types(model_cls):
|
||||
# Gives us `item_types` and `album_types`
|
||||
attr_name = '{0}_types'.format(model_cls.__name__.lower())
|
||||
types = {}
|
||||
for plugin in find_plugins():
|
||||
plugin_types = getattr(plugin, attr_name, {})
|
||||
for field in plugin_types:
|
||||
if field in types:
|
||||
raise PluginConflictException(
|
||||
u'Plugin {0} defines flexible field {1} '
|
||||
'which has already been defined.'
|
||||
.format(plugin.name,))
|
||||
types.update(plugin_types)
|
||||
return types
|
||||
|
||||
|
||||
def track_distance(item, info):
|
||||
"""Gets the track distance calculated by all loaded plugins.
|
||||
Returns a Distance object.
|
||||
|
|
|
|||
|
|
@ -869,6 +869,8 @@ def _setup(options, lib=None):
|
|||
if lib is None:
|
||||
lib = _open_library(config)
|
||||
plugins.send("library_opened", lib=lib)
|
||||
library.Item._types = plugins.types(library.Item)
|
||||
library.Album._types = plugins.types(library.Album)
|
||||
|
||||
return subcommands, plugins, lib
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from string import Template
|
|||
import subprocess
|
||||
|
||||
from beets import util, config, plugins, ui
|
||||
from beets.dbcore import types
|
||||
import pyechonest
|
||||
import pyechonest.song
|
||||
import pyechonest.track
|
||||
|
|
@ -38,7 +39,9 @@ DEVNULL = open(os.devnull, 'wb')
|
|||
ALLOWED_FORMATS = ('MP3', 'OGG', 'AAC')
|
||||
UPLOAD_MAX_SIZE = 50 * 1024 * 1024
|
||||
|
||||
# The attributes we can import and where to store them in beets fields.
|
||||
# Maps attribute names from echonest to their field names in beets.
|
||||
# The attributes are retrieved from a songs `audio_summary`. See:
|
||||
# http://echonest.github.io/pyechonest/song.html#pyechonest.song.profile
|
||||
ATTRIBUTES = {
|
||||
'energy': 'energy',
|
||||
'liveness': 'liveness',
|
||||
|
|
@ -49,6 +52,16 @@ ATTRIBUTES = {
|
|||
'tempo': 'bpm',
|
||||
}
|
||||
|
||||
# Types for the flexible fields added by `ATTRIBUTES`
|
||||
FIELD_TYPES = {
|
||||
'energy': types.FLOAT,
|
||||
'liveness': types.FLOAT,
|
||||
'speechiness': types.FLOAT,
|
||||
'acousticness': types.FLOAT,
|
||||
'danceability': types.FLOAT,
|
||||
'valence': types.FLOAT,
|
||||
}
|
||||
|
||||
MUSICAL_SCALE = ['C', 'C#', 'D', 'D#', 'E' 'F',
|
||||
'F#', 'G', 'G#', 'A', 'A#', 'B']
|
||||
|
||||
|
|
@ -104,6 +117,9 @@ def similar(lib, src_item, threshold=0.15, fmt='${difference}: ${path}'):
|
|||
|
||||
|
||||
class EchonestMetadataPlugin(plugins.BeetsPlugin):
|
||||
|
||||
item_types = FIELD_TYPES
|
||||
|
||||
def __init__(self):
|
||||
super(EchonestMetadataPlugin, self).__init__()
|
||||
self.config.add({
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ from beets import config
|
|||
from beets import plugins
|
||||
from beets import library
|
||||
from beets.util import displayable_path
|
||||
from beets.dbcore import types
|
||||
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
|
|
@ -308,6 +309,14 @@ class MPDStats(object):
|
|||
|
||||
|
||||
class MPDStatsPlugin(plugins.BeetsPlugin):
|
||||
|
||||
item_types = {
|
||||
'play_count': types.INTEGER,
|
||||
'skip_count': types.INTEGER,
|
||||
'last_played': library.Date(),
|
||||
'rating': types.FLOAT,
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
super(MPDStatsPlugin, self).__init__()
|
||||
self.config.add({
|
||||
|
|
|
|||
42
beetsplug/types.py
Normal file
42
beetsplug/types.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Thomas Scholtes.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.dbcore import types
|
||||
from beets.util.confit import ConfigValueError
|
||||
from beets import library
|
||||
|
||||
|
||||
class TypesPlugin(BeetsPlugin):
|
||||
|
||||
@property
|
||||
def item_types(self):
|
||||
if not self.config.exists():
|
||||
return {}
|
||||
|
||||
mytypes = {}
|
||||
for key, value in self.config.items():
|
||||
if value.get() == 'int':
|
||||
mytypes[key] = types.INTEGER
|
||||
elif value.get() == 'float':
|
||||
mytypes[key] = types.FLOAT
|
||||
elif value.get() == 'bool':
|
||||
mytypes[key] = types.BOOLEAN
|
||||
elif value.get() == 'date':
|
||||
mytypes[key] = library.DateType()
|
||||
else:
|
||||
raise ConfigValueError(
|
||||
u"unknown type '{0}' for the '{1}' field"
|
||||
.format(value, key))
|
||||
return mytypes
|
||||
|
|
@ -397,3 +397,37 @@ plugin will be used if we issue a command like ``beet ls @something`` or
|
|||
return {
|
||||
'@': ExactMatchQuery
|
||||
}
|
||||
|
||||
|
||||
Flexible Field Types
|
||||
^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
If your plugin uses flexible fields to store numbers or other
|
||||
non-string values you can specify the types of those fields. A rating
|
||||
plugin, for example might look like this. ::
|
||||
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.dbcore import types
|
||||
|
||||
class RatingPlugin(BeetsPlugin):
|
||||
item_types = {'rating': types.INTEGER}
|
||||
|
||||
@property
|
||||
def album_types(self):
|
||||
return {'rating': types.INTEGER}
|
||||
|
||||
A plugin may define two attributes, `item_types` and `album_types`.
|
||||
Each of those attributes is a dictionary mapping a flexible field name
|
||||
to a type instance. You can find the built-in types in the
|
||||
`beets.dbcore.types` and `beets.library` modules or implement your own
|
||||
ones.
|
||||
|
||||
Specifying types has the following advantages.
|
||||
|
||||
* The flexible field accessors ``item['my_field']`` return the
|
||||
specified type instead of a string.
|
||||
|
||||
* Users can use advanced queries (like :ref:`ranges <numericquery>`)
|
||||
from the command line.
|
||||
|
||||
* User input for flexible fields may be validated.
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ by typing ``beet version``.
|
|||
importadded
|
||||
bpm
|
||||
spotify
|
||||
types
|
||||
|
||||
Autotagger Extensions
|
||||
---------------------
|
||||
|
|
@ -137,6 +138,7 @@ Miscellaneous
|
|||
* :doc:`missing`: List missing tracks.
|
||||
* :doc:`duplicates`: List duplicate tracks or albums.
|
||||
* :doc:`spotify`: Create Spotify playlists from the Beets library.
|
||||
* :doc:`types`: Declare types for flexible attributes.
|
||||
|
||||
.. _MPD: http://www.musicpd.org/
|
||||
.. _MPD clients: http://mpd.wikia.com/wiki/Clients
|
||||
|
|
|
|||
17
docs/plugins/types.rst
Normal file
17
docs/plugins/types.rst
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
Types Plugin
|
||||
============
|
||||
|
||||
The ``types`` plugin lets you declare types for attributes you use in your
|
||||
library. For example, you can declare that a ``rating`` field is numeric so
|
||||
that you can query it with ranges---which isn't possible when the field is
|
||||
considered a string, which is the default.
|
||||
|
||||
Enable the plugin as described in :doc:`/plugins/index` and then add a
|
||||
``types`` section to your :doc:`configuration file </reference/config>`. The
|
||||
configuration section should map field name to one of ``int``, ``float``,
|
||||
``bool``, or ``date``.
|
||||
|
||||
Here's an example:
|
||||
|
||||
types:
|
||||
rating: int
|
||||
|
|
@ -43,7 +43,7 @@ from enum import Enum
|
|||
import beets
|
||||
from beets import config
|
||||
import beets.plugins
|
||||
from beets.library import Library, Item
|
||||
from beets.library import Library, Item, Album
|
||||
from beets import importer
|
||||
from beets.autotag.hooks import AlbumInfo, TrackInfo
|
||||
from beets.mediafile import MediaFile
|
||||
|
|
@ -168,18 +168,24 @@ class TestHelper(object):
|
|||
Similar setting a list of plugins in the configuration. Make
|
||||
sure you call ``unload_plugins()`` afterwards.
|
||||
"""
|
||||
# FIXME this should eventually be handled by a plugin manager
|
||||
beets.config['plugins'] = plugins
|
||||
beets.plugins.load_plugins(plugins)
|
||||
beets.plugins.find_plugins()
|
||||
Item._types = beets.plugins.types(Item)
|
||||
Album._types = beets.plugins.types(Album)
|
||||
|
||||
def unload_plugins(self):
|
||||
"""Unload all plugins and remove the from the configuration.
|
||||
"""
|
||||
# FIXME this should eventually be handled by a plugin manager
|
||||
beets.config['plugins'] = []
|
||||
for plugin in beets.plugins._classes:
|
||||
plugin.listeners = None
|
||||
beets.plugins._classes = set()
|
||||
beets.plugins._instances = {}
|
||||
Item._types = {}
|
||||
Album._types = {}
|
||||
|
||||
def create_importer(self, item_count=1, album_count=1):
|
||||
"""Create files to import and return corresponding session.
|
||||
|
|
|
|||
|
|
@ -70,9 +70,17 @@ class EchonestCliTest(unittest.TestCase, TestHelper):
|
|||
|
||||
self.run_command('echonest')
|
||||
item.load()
|
||||
self.assertEqual(item['danceability'], '0.5')
|
||||
self.assertEqual(item['danceability'], 0.5)
|
||||
self.assertEqual(item['liveness'], 0.5)
|
||||
self.assertEqual(item['bpm'], 120)
|
||||
self.assertEqual(item['initial_key'], 'C#m')
|
||||
|
||||
def test_custom_field_range_query(self):
|
||||
item = Item(liveness=2.2)
|
||||
item.add(self.lib)
|
||||
item = self.lib.items('liveness:2.2..3').get()
|
||||
self.assertEqual(item['liveness'], 2.2)
|
||||
|
||||
|
||||
def suite():
|
||||
return unittest.TestLoader().loadTestsFromName(__name__)
|
||||
|
|
|
|||
75
test/test_plugins.py
Normal file
75
test/test_plugins.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Thomas Scholtes.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
from mock import patch
|
||||
from _common import unittest
|
||||
from helper import TestHelper
|
||||
|
||||
from beets import plugins
|
||||
from beets.library import Item
|
||||
from beets.dbcore import types
|
||||
|
||||
|
||||
class PluginTest(unittest.TestCase, TestHelper):
|
||||
|
||||
def setUp(self):
|
||||
# FIXME the mocking code is horrific, but this is the lowest and
|
||||
# earliest level of the plugin mechanism we can hook into.
|
||||
self._plugin_loader_patch = patch('beets.plugins.load_plugins')
|
||||
self._plugin_classes = set()
|
||||
load_plugins = self._plugin_loader_patch.start()
|
||||
|
||||
def myload(names=()):
|
||||
plugins._classes.update(self._plugin_classes)
|
||||
load_plugins.side_effect = myload
|
||||
self.setup_beets()
|
||||
|
||||
def tearDown(self):
|
||||
self._plugin_loader_patch.stop()
|
||||
self.unload_plugins()
|
||||
self.teardown_beets()
|
||||
|
||||
def test_flex_field_type(self):
|
||||
class RatingPlugin(plugins.BeetsPlugin):
|
||||
item_types = {'rating': types.Float()}
|
||||
|
||||
self.register_plugin(RatingPlugin)
|
||||
self.config['plugins'] = 'rating'
|
||||
|
||||
item = Item(path='apath', artist='aaa')
|
||||
item.add(self.lib)
|
||||
|
||||
# Do not match unset values
|
||||
out = self.run_with_output('ls', 'rating:1..3')
|
||||
self.assertNotIn('aaa', out)
|
||||
|
||||
self.run_command('modify', 'rating=2', '--yes')
|
||||
|
||||
# Match in range
|
||||
out = self.run_with_output('ls', 'rating:1..3')
|
||||
self.assertIn('aaa', out)
|
||||
|
||||
# Don't match out of range
|
||||
out = self.run_with_output('ls', 'rating:3..5')
|
||||
self.assertNotIn('aaa', out)
|
||||
|
||||
def register_plugin(self, plugin_class):
|
||||
self._plugin_classes.add(plugin_class)
|
||||
|
||||
|
||||
def suite():
|
||||
return unittest.TestLoader().loadTestsFromName(__name__)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(defaultTest='suite')
|
||||
132
test/test_types_plugin.py
Normal file
132
test/test_types_plugin.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Thomas Scholtes.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from _common import unittest
|
||||
from helper import TestHelper
|
||||
|
||||
from beets.util.confit import ConfigValueError
|
||||
|
||||
|
||||
class TypesPluginTest(unittest.TestCase, TestHelper):
|
||||
|
||||
def setUp(self):
|
||||
self.setup_beets()
|
||||
self.load_plugins('types')
|
||||
|
||||
def tearDown(self):
|
||||
self.unload_plugins()
|
||||
self.teardown_beets()
|
||||
|
||||
def test_integer_modify_and_query(self):
|
||||
self.config['types'] = {'myint': 'int'}
|
||||
item = self.add_item(artist='aaa')
|
||||
|
||||
# Do not match unset values
|
||||
out = self.list('myint:1..3')
|
||||
self.assertEqual('', out)
|
||||
|
||||
self.modify('myint=2')
|
||||
item.load()
|
||||
self.assertEqual(item['myint'], 2)
|
||||
|
||||
# Match in range
|
||||
out = self.list('myint:1..3')
|
||||
self.assertIn('aaa', out)
|
||||
|
||||
def test_float_modify_and_query(self):
|
||||
self.config['types'] = {'myfloat': 'float'}
|
||||
item = self.add_item(artist='aaa')
|
||||
|
||||
self.modify('myfloat=-9.1')
|
||||
item.load()
|
||||
self.assertEqual(item['myfloat'], -9.1)
|
||||
|
||||
# Match in range
|
||||
out = self.list('myfloat:-10..0')
|
||||
self.assertIn('aaa', out)
|
||||
|
||||
def test_bool_modify_and_query(self):
|
||||
self.config['types'] = {'mybool': 'bool'}
|
||||
true = self.add_item(artist='true')
|
||||
false = self.add_item(artist='false')
|
||||
self.add_item(artist='unset')
|
||||
|
||||
# Set true
|
||||
self.modify('mybool=1', 'artist:true')
|
||||
true.load()
|
||||
self.assertEqual(true['mybool'], True)
|
||||
|
||||
# Set false
|
||||
self.modify('mybool=false', 'artist:false')
|
||||
false.load()
|
||||
self.assertEqual(false['mybool'], False)
|
||||
|
||||
# Query bools
|
||||
out = self.list('mybool:true', '$artist $mybool')
|
||||
self.assertEqual('true True', out)
|
||||
|
||||
out = self.list('mybool:false', '$artist $mybool')
|
||||
|
||||
# Dealing with unset fields?
|
||||
# self.assertEqual('false False', out)
|
||||
# out = self.list('mybool:', '$artist $mybool')
|
||||
# self.assertIn('unset $mybool', out)
|
||||
|
||||
def test_date_modify_and_query(self):
|
||||
self.config['types'] = {'mydate': 'date'}
|
||||
# FIXME parsing should also work with default time format
|
||||
self.config['time_format'] = '%Y-%m-%d'
|
||||
old = self.add_item(artist='prince')
|
||||
new = self.add_item(artist='britney')
|
||||
|
||||
self.modify('mydate=1999-01-01', 'artist:prince')
|
||||
old.load()
|
||||
self.assertEqual(old['mydate'], mktime(1999, 01, 01))
|
||||
|
||||
self.modify('mydate=1999-12-30', 'artist:britney')
|
||||
new.load()
|
||||
self.assertEqual(new['mydate'], mktime(1999, 12, 30))
|
||||
|
||||
# Match in range
|
||||
out = self.list('mydate:..1999-07', '$artist $mydate')
|
||||
self.assertEqual('prince 1999-01-01', out)
|
||||
|
||||
# FIXME some sort of timezone issue here
|
||||
# out = self.list('mydate:1999-12-30', '$artist $mydate')
|
||||
# self.assertEqual('britney 1999-12-30', out)
|
||||
|
||||
def test_unknown_type_error(self):
|
||||
self.config['types'] = {'flex': 'unkown type'}
|
||||
with self.assertRaises(ConfigValueError):
|
||||
self.run_command('ls')
|
||||
|
||||
def modify(self, *args):
|
||||
return self.run_with_output('modify', '--yes', '--nowrite', *args)
|
||||
|
||||
def list(self, query, fmt='$artist - $album - $title'):
|
||||
return self.run_with_output('ls', '-f', fmt, query).strip()
|
||||
|
||||
|
||||
def mktime(*args):
|
||||
return time.mktime(datetime(*args).timetuple())
|
||||
|
||||
|
||||
def suite():
|
||||
return unittest.TestLoader().loadTestsFromName(__name__)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(defaultTest='suite')
|
||||
Loading…
Reference in a new issue