diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 2063cff72..0ec24dfd6 100644 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -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); diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index 7197d2564..1f1a9a26a 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -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) diff --git a/beets/library.py b/beets/library.py index f053728b1..3a6829176 100644 --- a/beets/library.py +++ b/beets/library.py @@ -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 diff --git a/beets/plugins.py b/beets/plugins.py index 3dca22a97..2ee5f88f2 100755 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -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. diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 7e3e7559c..c3bb7a158 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -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 diff --git a/beetsplug/echonest.py b/beetsplug/echonest.py index e183eb8ef..e31a8c425 100644 --- a/beetsplug/echonest.py +++ b/beetsplug/echonest.py @@ -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({ diff --git a/beetsplug/mpdstats.py b/beetsplug/mpdstats.py index 56522a8de..f03e284e3 100644 --- a/beetsplug/mpdstats.py +++ b/beetsplug/mpdstats.py @@ -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({ diff --git a/beetsplug/types.py b/beetsplug/types.py new file mode 100644 index 000000000..68aea35c7 --- /dev/null +++ b/beetsplug/types.py @@ -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 diff --git a/docs/dev/plugins.rst b/docs/dev/plugins.rst index ffb2d0721..c585c9001 100644 --- a/docs/dev/plugins.rst +++ b/docs/dev/plugins.rst @@ -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 `) + from the command line. + +* User input for flexible fields may be validated. diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index cd1ea868e..b66d801ea 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -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 diff --git a/docs/plugins/types.rst b/docs/plugins/types.rst new file mode 100644 index 000000000..41419d758 --- /dev/null +++ b/docs/plugins/types.rst @@ -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 `. The +configuration section should map field name to one of ``int``, ``float``, +``bool``, or ``date``. + +Here's an example: + + types: + rating: int diff --git a/test/helper.py b/test/helper.py index 0688708fd..f706871a7 100644 --- a/test/helper.py +++ b/test/helper.py @@ -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. diff --git a/test/test_echonest.py b/test/test_echonest.py index da05adde3..43f006dfc 100644 --- a/test/test_echonest.py +++ b/test/test_echonest.py @@ -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__) diff --git a/test/test_plugins.py b/test/test_plugins.py new file mode 100644 index 000000000..a48dd10ae --- /dev/null +++ b/test/test_plugins.py @@ -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') diff --git a/test/test_types_plugin.py b/test/test_types_plugin.py new file mode 100644 index 000000000..ef7ac7aa9 --- /dev/null +++ b/test/test_types_plugin.py @@ -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')