query-conditioned path formats (#210)

Also, Library.path_formats is now a list of pairs instead of a dictionary. (I
would have used an OrderedDict, but that was added in 2.7.)
This commit is contained in:
Adrian Sampson 2011-12-28 19:01:13 -08:00
parent a1b2b6c8a2
commit d73c133a53
9 changed files with 198 additions and 115 deletions

View file

@ -17,6 +17,7 @@ import os
import re
import sys
import logging
import shlex
from unidecode import unidecode
from beets.mediafile import MediaFile
from beets import plugins
@ -103,6 +104,9 @@ ALBUM_DEFAULT_FIELDS = ('album', 'albumartist', 'genre')
ITEM_DEFAULT_FIELDS = ARTIST_DEFAULT_FIELDS + ALBUM_DEFAULT_FIELDS + \
('title', 'comments')
# Special path format key.
PF_KEY_DEFAULT = 'default'
# Logger.
log = logging.getLogger('beets')
if not log.handlers:
@ -350,8 +354,8 @@ class CollectionQuery(Query):
# is there a better way to do this?
def __len__(self): return len(self.subqueries)
def __getitem__(self, key): return self.subqueries[key]
def __iter__(self): iter(self.subqueries)
def __contains__(self, item): item in self.subqueries
def __iter__(self): return iter(self.subqueries)
def __contains__(self, item): return item in self.subqueries
def clause_with_joiner(self, joiner):
"""Returns a clause created by joining together the clauses of
@ -425,6 +429,13 @@ class CollectionQuery(Query):
subqueries = [TrueQuery()]
return cls(subqueries)
@classmethod
def from_string(cls, query, default_fields=None, all_keys=ITEM_KEYS):
"""Creates a query based on a single string. The string is split
into query parts using shell-style syntax.
"""
return cls.from_strings(shlex.split(query))
class AnySubstringQuery(CollectionQuery):
"""A query that matches a substring in any of a list of metadata
fields.
@ -479,6 +490,14 @@ class TrueQuery(Query):
def match(self, item):
return True
class FalseQuery(Query):
"""A query that never matches."""
def clause(self):
return '0', ()
def match(self, item):
return False
class PathQuery(Query):
"""A query that matches all items under a given path."""
def __init__(self, path):
@ -702,7 +721,8 @@ class Library(BaseLibrary):
"""A music library using an SQLite database as a metadata store."""
def __init__(self, path='library.blb',
directory='~/Music',
path_formats=None,
path_formats=((PF_KEY_DEFAULT,
'$artist/$album/$track $title'),),
art_filename='cover',
timeout=5.0,
replacements=None,
@ -713,10 +733,6 @@ class Library(BaseLibrary):
else:
self.path = bytestring_path(normpath(path))
self.directory = bytestring_path(normpath(directory))
if path_formats is None:
path_formats = {'default': '$artist/$album/$track $title'}
elif isinstance(path_formats, basestring):
path_formats = {'default': path_formats}
self.path_formats = path_formats
self.art_filename = bytestring_path(art_filename)
self.replacements = replacements
@ -784,19 +800,31 @@ class Library(BaseLibrary):
"""
pathmod = pathmod or os.path
# Use a path format based on the album type, if available.
if not item.album_id and not in_album:
# Singleton track. Never use the "album" formats.
if 'singleton' in self.path_formats:
path_format = self.path_formats['singleton']
else:
path_format = self.path_formats['default']
elif item.albumtype and item.albumtype in self.path_formats:
path_format = self.path_formats[item.albumtype]
elif item.comp and 'comp' in self.path_formats:
path_format = self.path_formats['comp']
# Use a path format based on a query, falling back on the
# default.
for query, path_format in self.path_formats:
if query == PF_KEY_DEFAULT:
continue
query = AndQuery.from_string(query)
if in_album:
# If we're treating this item as a member of the item,
# hack the query so that singleton queries always
# observe the item to be non-singleton.
for i, subquery in enumerate(query):
if isinstance(subquery, SingletonQuery):
query[i] = FalseQuery() if subquery.sense \
else TrueQuery()
if query.match(item):
# The query matches the item! Use the corresponding path
# format.
break
else:
path_format = self.path_formats['default']
# No query matched; fall back to default.
for query, path_format in self.path_formats:
if query == PF_KEY_DEFAULT:
break
else:
assert False, "no default path format"
subpath_tmpl = Template(path_format)
# Get the item's Album if it has one.

View file

@ -40,15 +40,18 @@ DEFAULT_LIBRARY_FILENAME_UNIX = '.beetsmusic.blb'
DEFAULT_LIBRARY_FILENAME_WINDOWS = 'beetsmusic.blb'
DEFAULT_DIRECTORY_NAME = 'Music'
WINDOWS_BASEDIR = os.environ.get('APPDATA') or '~'
DEFAULT_PATH_FORMATS = {
'default': '$albumartist/$album/$track $title',
'comp': 'Compilations/$album/$track $title',
'singleton': 'Non-Album/$artist/$title',
PF_KEY_QUERIES = {
'comp': 'comp:true',
'singleton': 'singleton:true',
}
DEFAULT_PATH_FORMATS = [
(library.PF_KEY_DEFAULT, '$albumartist/$album/$track $title'),
(PF_KEY_QUERIES['singleton'], 'Non-Album/$artist/$title'),
(PF_KEY_QUERIES['comp'], 'Compilations/$album/$track $title'),
]
DEFAULT_ART_FILENAME = 'cover'
DEFAULT_TIMEOUT = 5.0
# UI exception. Commands should throw this in order to display
# nonrecoverable errors to the user.
class UserError(Exception):
@ -429,6 +432,31 @@ def _get_replacements(config):
out.append((re.compile(pattern), replacement))
return out
def _get_path_formats(config):
"""Returns a list of path formats (query/template pairs); reflecting
the config's specified path formats.
"""
legacy_path_format = config_val(config, 'beets', 'path_format', None)
if legacy_path_format:
# Old path formats override the default values.
path_formats = [(library.PF_KEY_DEFAULT, legacy_path_format)]
else:
# If no legacy path format, use the defaults instead.
path_formats = DEFAULT_PATH_FORMATS
if config.has_section('paths'):
custom_path_formats = []
for key, value in config.items('paths', True):
if key in PF_KEY_QUERIES:
# Special values that indicate simple queries.
key = PF_KEY_QUERIES[key]
elif key != library.PF_KEY_DEFAULT:
# For non-special keys (literal queries), the _
# character denotes a :.
key = key.replace('_', ':')
custom_path_formats.append((key, value))
path_formats = custom_path_formats + path_formats
return path_formats
# Subcommand parsing infrastructure.
@ -648,15 +676,7 @@ def main(args=None, configfh=None):
config_val(config, 'beets', 'library', default_libpath)
directory = options.directory or \
config_val(config, 'beets', 'directory', default_dir)
legacy_path_format = config_val(config, 'beets', 'path_format', None)
if legacy_path_format:
# Old path formats override the default values.
path_formats = {'default': legacy_path_format}
else:
# If no legacy path format, use the defaults instead.
path_formats = DEFAULT_PATH_FORMATS
if config.has_section('paths'):
path_formats.update(config.items('paths', True))
path_formats = _get_path_formats(config)
art_filename = \
config_val(config, 'beets', 'art_filename', DEFAULT_ART_FILENAME)
lib_timeout = config_val(config, 'beets', 'timeout', DEFAULT_TIMEOUT)

View file

@ -14,6 +14,12 @@ This release focuses on making beets' path formatting vastly more powerful.
* Plugins can also now define new path *fields* in addition to functions.
* The new :doc:`/plugins/inline` lets you **use Python expressions to customize
path formats** by defining new fields in the config file.
* The configuration can **condition path formats based on queries**. That is,
you can write a path format that is only used if an item matches a given
query. (This supersedes the earlier functionality that only allowed
conditioning on album type; if you used this feature in a previous version,
you will need to replace, for example, ``soundtrack:`` with
``albumtype_soundtrack:``.) See :ref:`path-format-config`.
* **Filename substitutions are now configurable** via the ``replace`` config
value. You can choose which characters you think should be allowed in your
directory and music file names. See :doc:`/reference/config`.
@ -287,7 +293,7 @@ that functionality.
Tracks" (T) option to add singletons to your library. To list only singleton
or only album tracks, use the new ``singleton:`` query term: the query
``singleton:true`` matches only singleton tracks; ``singleton:false`` matches
only album tracks. The :doc:`/plugins/lastid` has been extended to support
only album tracks. The ``lastid`` plugin has been extended to support
matching individual items as well.
* The importer/autotagger system has been heavily refactored in this release.
@ -474,7 +480,7 @@ it more reliable. This release also greatly expands the capabilities of beets'
:doc:`plugin API </plugins/index>`. A host of other little features and fixes
are also rolled into this release.
* The :doc:`/plugins/lastid` adds Last.fm **acoustic fingerprinting
* The ``lastid`` plugin adds Last.fm **acoustic fingerprinting
support** to the autotagger. Similar to the PUIDs used by !MusicBrainz Picard,
this system allows beets to recognize files that don't have any metadata at
all. You'll need to install some dependencies for this plugin to work.

View file

@ -130,31 +130,40 @@ section header:
exception when the database lock is contended. This should almost never need
to be changed except on very slow systems. Defaults to 5.0 (5 seconds).
Path Formats
------------
.. _path-format-config:
Path Format Configuration
-------------------------
You can also configure the directory hierarchy beets uses to store music. These
settings appear under the ``[paths]`` section (rather than the main ``[beets]``
section we used above). Each string is a `template string`_ that can refer to
section we used above). Each string is a template string that can refer to
metadata fields like ``$artist`` or ``$title``. The filename extension is added
automatically. At the moment, you can specify three special paths: ``default``
for most releases, ``comp`` for "various artist" releases with no dominant
artist, and ``singleton`` for non-album tracks. You can also specify a different
path format for each `MusicBrainz release type`_. The defaults look like this::
artist, and ``singleton`` for non-album tracks. The defaults look like this::
[paths]
default: $albumartist/$album/$track $title
comp: Compilations/$album/$track title
singleton: Non-Album/$artist/$title
comp: Compilations/$album/$track title
Note the use of ``$albumartist`` instead of ``$artist``; this ensure that albums
will be well-organized. For more about these format strings, see
:doc:`pathformat`.
.. _template string:
http://docs.python.org/library/string.html#template-strings
.. _MusicBrainz release type:
http://wiki.musicbrainz.org/ReleaseType
In addition to ``default``, ``comp``, and ``singleton``, you can condition path
queries based on beets queries (see :doc:`/reference/query`). There's one catch:
because the ``:`` character is reserved for separating the query from the
template string, the ``_`` character is substituted for ``:`` in these queries.
This means that a config file like this::
[paths]
albumtype_soundtrack: Soundtracks/$albumartist/$track title
will place soundtrack albums in a separate directory. The queries are tested in
the order they appear in the configuration file, meaning that if an item matches
multiple queries, beets will use the path format for the *first* matching query.
Example
-------

View file

@ -138,17 +138,17 @@ class DestinationTest(unittest.TestCase):
def test_directory_works_with_trailing_slash(self):
self.lib.directory = 'one/'
self.lib.path_formats = {'default': 'two'}
self.lib.path_formats = [('default', 'two')]
self.assertEqual(self.lib.destination(self.i), np('one/two'))
def test_directory_works_without_trailing_slash(self):
self.lib.directory = 'one'
self.lib.path_formats = {'default': 'two'}
self.lib.path_formats = [('default', 'two')]
self.assertEqual(self.lib.destination(self.i), np('one/two'))
def test_destination_substitues_metadata_values(self):
self.lib.directory = 'base'
self.lib.path_formats = {'default': '$album/$artist $title'}
self.lib.path_formats = [('default', '$album/$artist $title')]
self.i.title = 'three'
self.i.artist = 'two'
self.i.album = 'one'
@ -157,15 +157,15 @@ class DestinationTest(unittest.TestCase):
def test_destination_preserves_extension(self):
self.lib.directory = 'base'
self.lib.path_formats = {'default': '$title'}
self.lib.path_formats = [('default', '$title')]
self.i.path = 'hey.audioFormat'
self.assertEqual(self.lib.destination(self.i),
np('base/the title.audioFormat'))
def test_destination_pads_some_indices(self):
self.lib.directory = 'base'
self.lib.path_formats = {'default': '$track $tracktotal ' \
'$disc $disctotal $bpm'}
self.lib.path_formats = [('default', '$track $tracktotal ' \
'$disc $disctotal $bpm')]
self.i.track = 1
self.i.tracktotal = 2
self.i.disc = 3
@ -176,7 +176,7 @@ class DestinationTest(unittest.TestCase):
def test_destination_pads_date_values(self):
self.lib.directory = 'base'
self.lib.path_formats = {'default': '$year-$month-$day'}
self.lib.path_formats = [('default', '$year-$month-$day')]
self.i.year = 1
self.i.month = 2
self.i.day = 3
@ -245,7 +245,7 @@ class DestinationTest(unittest.TestCase):
self.assertEqual(p, u'-')
def test_path_with_format(self):
self.lib.path_formats = {'default': '$artist/$album ($format)'}
self.lib.path_formats = [('default', '$artist/$album ($format)')]
p = self.lib.destination(self.i)
self.assert_('(FLAC)' in p)
@ -253,7 +253,7 @@ class DestinationTest(unittest.TestCase):
i1, i2 = item(), item()
self.lib.add_album([i1, i2])
i1.year, i2.year = 2009, 2010
self.lib.path_formats = {'default': '$album ($year)/$track $title'}
self.lib.path_formats = [('default', '$album ($year)/$track $title')]
dest1, dest2 = self.lib.destination(i1), self.lib.destination(i2)
self.assertEqual(os.path.dirname(dest1), os.path.dirname(dest2))
@ -261,44 +261,51 @@ class DestinationTest(unittest.TestCase):
self.i.comp = False
self.lib.add_album([self.i])
self.lib.directory = 'one'
self.lib.path_formats = {'default': 'two',
'comp': 'three'}
self.lib.path_formats = [('default', 'two'),
('comp:true', 'three')]
self.assertEqual(self.lib.destination(self.i), np('one/two'))
def test_singleton_path(self):
i = item()
self.lib.directory = 'one'
self.lib.path_formats = {'default': 'two',
'comp': 'three',
'singleton': 'four'}
self.lib.path_formats = [
('default', 'two'),
('singleton:true', 'four'),
('comp:true', 'three'),
]
self.assertEqual(self.lib.destination(i), np('one/four'))
def test_singleton_track_falls_back_to_default(self):
def test_comp_before_singleton_path(self):
i = item()
i.comp = True
i.albumtype = 'atype'
self.lib.directory = 'one'
self.lib.path_formats = {'default': 'two',
'comp': 'three',
'atype': 'four'}
self.assertEqual(self.lib.destination(i), np('one/two'))
self.lib.path_formats = [
('default', 'two'),
('comp:true', 'three'),
('singleton:true', 'four'),
]
self.assertEqual(self.lib.destination(i), np('one/three'))
def test_comp_path(self):
self.i.comp = True
self.lib.add_album([self.i])
self.lib.directory = 'one'
self.lib.path_formats = {'default': 'two',
'comp': 'three'}
self.lib.path_formats = [
('default', 'two'),
('comp:true', 'three'),
]
self.assertEqual(self.lib.destination(self.i), np('one/three'))
def test_albumtype_path(self):
def test_albumtype_query_path(self):
self.i.comp = True
self.lib.add_album([self.i])
self.i.albumtype = 'sometype'
self.lib.directory = 'one'
self.lib.path_formats = {'default': 'two',
'comp': 'three',
'sometype': 'four'}
self.lib.path_formats = [
('default', 'two'),
('albumtype:sometype', 'four'),
('comp:true', 'three'),
]
self.assertEqual(self.lib.destination(self.i), np('one/four'))
def test_albumtype_path_fallback_to_comp(self):
@ -306,9 +313,11 @@ class DestinationTest(unittest.TestCase):
self.lib.add_album([self.i])
self.i.albumtype = 'sometype'
self.lib.directory = 'one'
self.lib.path_formats = {'default': 'two',
'comp': 'three',
'anothertype': 'four'}
self.lib.path_formats = [
('default', 'two'),
('albumtype:anothertype', 'four'),
('comp:true', 'three'),
]
self.assertEqual(self.lib.destination(self.i), np('one/three'))
def test_syspath_windows_format(self):
@ -342,28 +351,28 @@ class DestinationTest(unittest.TestCase):
def test_artist_falls_back_to_albumartist(self):
self.i.artist = ''
self.i.albumartist = 'something'
self.lib.path_formats = {'default': '$artist'}
self.lib.path_formats = [('default', '$artist')]
p = self.lib.destination(self.i)
self.assertEqual(p.rsplit(os.path.sep, 1)[1], 'something')
def test_albumartist_falls_back_to_artist(self):
self.i.artist = 'trackartist'
self.i.albumartist = ''
self.lib.path_formats = {'default': '$albumartist'}
self.lib.path_formats = [('default', '$albumartist')]
p = self.lib.destination(self.i)
self.assertEqual(p.rsplit(os.path.sep, 1)[1], 'trackartist')
def test_artist_overrides_albumartist(self):
self.i.artist = 'theartist'
self.i.albumartist = 'something'
self.lib.path_formats = {'default': '$artist'}
self.lib.path_formats = [('default', '$artist')]
p = self.lib.destination(self.i)
self.assertEqual(p.rsplit(os.path.sep, 1)[1], 'theartist')
def test_albumartist_overrides_artist(self):
self.i.artist = 'theartist'
self.i.albumartist = 'something'
self.lib.path_formats = {'default': '$albumartist'}
self.lib.path_formats = [('default', '$albumartist')]
p = self.lib.destination(self.i)
self.assertEqual(p.rsplit(os.path.sep, 1)[1], 'something')
@ -387,13 +396,13 @@ class DestinationFunctionTest(unittest.TestCase):
def setUp(self):
self.lib = beets.library.Library(':memory:')
self.lib.directory = '/base'
self.lib.path_formats = {'default': u'path'}
self.lib.path_formats = [('default', u'path')]
self.i = item()
def tearDown(self):
self.lib.conn.close()
def _setf(self, fmt):
self.lib.path_formats['default'] = fmt
self.lib.path_formats.insert(0, ('default', fmt))
def _assert_dest(self, dest):
self.assertEqual(self.lib.destination(self.i), dest)

View file

@ -40,7 +40,8 @@ class MoveTest(unittest.TestCase, _common.ExtraAsserts):
# set up the destination
self.libdir = join(_common.RSRC, 'testlibdir')
self.lib.directory = self.libdir
self.lib.path_formats = {'default': join('$artist', '$album', '$title')}
self.lib.path_formats = [('default',
join('$artist', '$album', '$title'))]
self.i.artist = 'one'
self.i.album = 'two'
self.i.title = 'three'
@ -157,7 +158,7 @@ class AlbumFileTest(unittest.TestCase):
# Make library and item.
self.lib = beets.library.Library(':memory:')
self.lib.path_formats = \
{'default': join('$albumartist', '$album', '$title')}
[('default', join('$albumartist', '$album', '$title'))]
self.libdir = os.path.join(_common.RSRC, 'testlibdir')
self.lib.directory = self.libdir
self.i = item()

View file

@ -34,9 +34,9 @@ class NonAutotaggedImportTest(unittest.TestCase):
self.lib = library.Library(self.libdb)
self.libdir = os.path.join(_common.RSRC, 'testlibdir')
self.lib.directory = self.libdir
self.lib.path_formats = {
'default': os.path.join('$artist', '$album', '$title')
}
self.lib.path_formats = [(
'default', os.path.join('$artist', '$album', '$title')
)]
self.srcdir = os.path.join(_common.RSRC, 'testsrcdir')
@ -164,11 +164,11 @@ class ImportApplyTest(unittest.TestCase, _common.ExtraAsserts):
self.libdir = os.path.join(_common.RSRC, 'testlibdir')
os.mkdir(self.libdir)
self.lib = library.Library(':memory:', self.libdir)
self.lib.path_formats = {
'default': 'one',
'comp': 'two',
'singleton': 'three',
}
self.lib.path_formats = [
('default', 'one'),
('singleton:true', 'three'),
('comp:true', 'two'),
]
self.srcpath = os.path.join(self.libdir, 'srcfile.mp3')
shutil.copy(os.path.join(_common.RSRC, 'full.mp3'), self.srcpath)
@ -212,17 +212,13 @@ class ImportApplyTest(unittest.TestCase, _common.ExtraAsserts):
coro = importer.apply_choices(_common.iconfig(self.lib))
coro.next() # Prime coroutine.
_call_apply_choice(coro, [self.i], importer.action.ASIS)
self.assertExists(
os.path.join(self.libdir, self.lib.path_formats['default']+'.mp3')
)
self.assertExists(os.path.join(self.libdir, 'one.mp3'))
def test_apply_match_uses_album_path(self):
coro = importer.apply_choices(_common.iconfig(self.lib))
coro.next() # Prime coroutine.
_call_apply(coro, [self.i], self.info)
self.assertExists(
os.path.join(self.libdir, self.lib.path_formats['default']+'.mp3')
)
self.assertExists(os.path.join(self.libdir, 'one.mp3'))
def test_apply_tracks_uses_singleton_path(self):
coro = importer.apply_choices(_common.iconfig(self.lib))
@ -233,7 +229,7 @@ class ImportApplyTest(unittest.TestCase, _common.ExtraAsserts):
coro.send(task)
self.assertExists(
os.path.join(self.libdir, self.lib.path_formats['singleton']+'.mp3')
os.path.join(self.libdir, 'three.mp3')
)
def test_apply_sentinel(self):
@ -296,9 +292,9 @@ class ApplyExistingItemsTest(unittest.TestCase, _common.ExtraAsserts):
self.dbpath = os.path.join(_common.RSRC, 'templib.blb')
self.lib = library.Library(self.dbpath, self.libdir)
self.lib.path_formats = {
'default': '$artist/$title',
}
self.lib.path_formats = [
('default', '$artist/$title'),
]
self.config = _common.iconfig(self.lib, write=False, copy=False)
self.srcpath = os.path.join(self.libdir, 'srcfile.mp3')

View file

@ -21,6 +21,7 @@ import textwrap
import logging
import re
from StringIO import StringIO
import ConfigParser
import _common
from beets import library
@ -478,27 +479,19 @@ class ConfigTest(unittest.TestCase):
def test_paths_section_respected(self):
def func(lib, config, opts, args):
self.assertEqual(lib.path_formats['x'], 'y')
self.assertEqual(lib.path_formats[0], ('x', 'y'))
self._run_main([], textwrap.dedent("""
[paths]
x=y"""), func)
def test_default_paths_preserved(self):
def func(lib, config, opts, args):
self.assertEqual(lib.path_formats['default'],
ui.DEFAULT_PATH_FORMATS['default'])
self.assertEqual(lib.path_formats[1:],
ui.DEFAULT_PATH_FORMATS)
self._run_main([], textwrap.dedent("""
[paths]
x=y"""), func)
def test_default_paths_overriden_by_legacy_path_format(self):
def func(lib, config, opts, args):
self.assertEqual(lib.path_formats['default'], 'x')
self.assertEqual(len(lib.path_formats), 1)
self._run_main([], textwrap.dedent("""
[beets]
path_format=x"""), func)
def test_nonexistant_config_file(self):
os.environ['BEETSCONFIG'] = '/xxxxx'
ui.main(['version'])
@ -718,6 +711,27 @@ class DefaultPathTest(unittest.TestCase):
self.assertEqual(lib, 'xappdata\\beetsmusic.blb')
self.assertEqual(libdir, 'xhome\\Music')
class PathFormatTest(unittest.TestCase):
def _config(self, text):
cp = ConfigParser.SafeConfigParser()
cp.readfp(StringIO(text))
return cp
def _paths_for(self, text):
return ui._get_path_formats(self._config("[paths]\n%s" %
textwrap.dedent(text)))
def test_default_paths(self):
pf = self._paths_for("")
self.assertEqual(pf, ui.DEFAULT_PATH_FORMATS)
def test_custom_paths_prepend(self):
pf = self._paths_for("""
foo: bar
""")
self.assertEqual(pf[0], ('foo', 'bar'))
self.assertEqual(pf[1:], ui.DEFAULT_PATH_FORMATS)
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)

View file

@ -21,10 +21,10 @@ from beets import vfs
class VFSTest(unittest.TestCase):
def setUp(self):
self.lib = library.Library(':memory:', path_formats={
'default': 'albums/$album/$title',
'singleton': 'tracks/$artist/$title',
})
self.lib = library.Library(':memory:', path_formats=[
('default', 'albums/$album/$title'),
('singleton:true', 'tracks/$artist/$title'),
])
self.lib.add(_common.item())
self.lib.add_album([_common.item()])
self.lib.save()