Merge branch 'master' into master

This commit is contained in:
A.L. Kleijngeld 2017-06-13 14:43:10 +02:00 committed by GitHub
commit 2eb4e3d515
20 changed files with 402 additions and 57 deletions

View file

@ -19,7 +19,7 @@ import os
from beets.util import confit
__version__ = u'1.4.4'
__version__ = u'1.4.5'
__author__ = u'Adrian Sampson <adrian@radbox.org>'

View file

@ -333,12 +333,26 @@ def album_info(release):
disambig.append(release.get('disambiguation'))
info.albumdisambig = u', '.join(disambig)
# Release type not always populated.
# Get the "classic" Release type. This data comes from a legacy API
# feature before MusicBrainz supported multiple release types.
if 'type' in release['release-group']:
reltype = release['release-group']['type']
if reltype:
info.albumtype = reltype.lower()
# Log the new-style "primary" and "secondary" release types.
# Eventually, we'd like to actually store this data, but we just log
# it for now to help understand the differences.
if 'primary-type' in release['release-group']:
rel_primarytype = release['release-group']['primary-type']
if rel_primarytype:
log.debug('primary MB release type: ' + rel_primarytype.lower())
if 'secondary-type-list' in release['release-group']:
if release['release-group']['secondary-type-list']:
log.debug('secondary MB release type(s): ' + ', '.join(
[secondarytype.lower() for secondarytype in
release['release-group']['secondary-type-list']]))
# Release events.
info.country, release_date = _preferred_release_event(release)
release_group_date = release['release-group'].get('first-release-date')

View file

@ -533,12 +533,20 @@ class Period(object):
instants of time during January 2014.
"""
precisions = ('year', 'month', 'day')
date_formats = ('%Y', '%Y-%m', '%Y-%m-%d')
precisions = ('year', 'month', 'day', 'hour', 'minute', 'second')
date_formats = (
('%Y',), # year
('%Y-%m',), # month
('%Y-%m-%d',), # day
('%Y-%m-%dT%H', '%Y-%m-%d %H'), # hour
('%Y-%m-%dT%H:%M', '%Y-%m-%d %H:%M'), # minute
('%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S') # second
)
def __init__(self, date, precision):
"""Create a period with the given date (a `datetime` object) and
precision (a string, one of "year", "month", or "day").
precision (a string, one of "year", "month", "day", "hour", "minute",
or "second").
"""
if precision not in Period.precisions:
raise ValueError(u'Invalid precision {0}'.format(precision))
@ -551,16 +559,21 @@ class Period(object):
string is empty, or raise an InvalidQueryArgumentValueError if
the string could not be parsed to a date.
"""
def find_date_and_format(string):
for ord, format in enumerate(cls.date_formats):
for format_option in format:
try:
date = datetime.strptime(string, format_option)
return date, ord
except ValueError:
# Parsing failed.
pass
return (None, None)
if not string:
return None
date = None
for ordinal, date_format in enumerate(cls.date_formats):
try:
date = datetime.strptime(string, date_format)
break
except ValueError:
# Parsing failed.
pass
date, ordinal = find_date_and_format(string)
if date is None:
raise InvalidQueryArgumentValueError(string,
'a valid datetime string')
@ -582,6 +595,12 @@ class Period(object):
return date.replace(year=date.year + 1, month=1)
elif 'day' == precision:
return date + timedelta(days=1)
elif 'hour' == precision:
return date + timedelta(hours=1)
elif 'minute' == precision:
return date + timedelta(minutes=1)
elif 'second' == precision:
return date + timedelta(seconds=1)
else:
raise ValueError(u'unhandled precision {0}'.format(precision))

View file

@ -454,6 +454,8 @@ class Item(LibModel):
'rg_track_peak': types.NULL_FLOAT,
'rg_album_gain': types.NULL_FLOAT,
'rg_album_peak': types.NULL_FLOAT,
'r128_track_gain': types.PaddedInt(6),
'r128_album_gain': types.PaddedInt(6),
'original_year': types.PaddedInt(4),
'original_month': types.PaddedInt(2),
'original_day': types.PaddedInt(2),
@ -898,6 +900,7 @@ class Album(LibModel):
'albumdisambig': types.STRING,
'rg_album_gain': types.NULL_FLOAT,
'rg_album_peak': types.NULL_FLOAT,
'r128_album_gain': types.PaddedInt(6),
'original_year': types.PaddedInt(4),
'original_month': types.PaddedInt(2),
'original_day': types.PaddedInt(2),
@ -941,6 +944,7 @@ class Album(LibModel):
'albumdisambig',
'rg_album_gain',
'rg_album_peak',
'r128_album_gain',
'original_year',
'original_month',
'original_day',

View file

@ -154,10 +154,12 @@ def _safe_cast(out_type, val):
return int(val)
else:
# Process any other type as a string.
if not isinstance(val, six.string_types):
if isinstance(val, bytes):
val = val.decode('utf-8', 'ignore')
elif not isinstance(val, six.string_types):
val = six.text_type(val)
# Get a number from the front of the string.
val = re.match(r'[0-9]*', val.strip()).group(0)
val = re.match(r'[\+-]?[0-9]*', val.strip()).group(0)
if not val:
return 0
else:
@ -2005,6 +2007,38 @@ class MediaFile(object):
out_type=float,
)
# EBU R128 fields.
r128_track_gain = MediaField(
MP3DescStorageStyle(
u'R128_TRACK_GAIN'
),
MP4StorageStyle(
'----:com.apple.iTunes:R128_TRACK_GAIN'
),
StorageStyle(
u'R128_TRACK_GAIN'
),
ASFStorageStyle(
u'R128_TRACK_GAIN'
),
out_type=int,
)
r128_album_gain = MediaField(
MP3DescStorageStyle(
u'R128_ALBUM_GAIN'
),
MP4StorageStyle(
'----:com.apple.iTunes:R128_ALBUM_GAIN'
),
StorageStyle(
u'R128_ALBUM_GAIN'
),
ASFStorageStyle(
u'R128_ALBUM_GAIN'
),
out_type=int,
)
initial_key = MediaField(
MP3StorageStyle('TKEY'),
MP4StorageStyle('----:com.apple.iTunes:initialkey'),

View file

@ -1457,7 +1457,8 @@ default_commands.append(modify_cmd)
# move: Move/copy files to the library or a new base directory.
def move_items(lib, dest, query, copy, album, pretend, confirm=False):
def move_items(lib, dest, query, copy, album, pretend, confirm=False,
export=False):
"""Moves or copies items to a new base directory, given by dest. If
dest is None, then the library's base directory is used, making the
command "consolidate" files.
@ -1470,6 +1471,7 @@ def move_items(lib, dest, query, copy, album, pretend, confirm=False):
isalbummoved = lambda album: any(isitemmoved(i) for i in album.items())
objs = [o for o in objs if (isalbummoved if album else isitemmoved)(o)]
copy = copy or export # Exporting always copies.
action = u'Copying' if copy else u'Moving'
act = u'copy' if copy else u'move'
entity = u'album' if album else u'item'
@ -1495,8 +1497,12 @@ def move_items(lib, dest, query, copy, album, pretend, confirm=False):
for obj in objs:
log.debug(u'moving: {0}', util.displayable_path(obj.path))
obj.move(copy, basedir=dest)
obj.store()
if export:
# Copy without affecting the database.
obj.move(True, basedir=dest, store=False)
else:
# Ordinary move/copy: store the new path.
obj.move(copy, basedir=dest)
def move_func(lib, opts, args):
@ -1507,7 +1513,7 @@ def move_func(lib, opts, args):
raise ui.UserError(u'no such directory: %s' % dest)
move_items(lib, dest, decargs(args), opts.copy, opts.album, opts.pretend,
opts.timid)
opts.timid, opts.export)
move_cmd = ui.Subcommand(
@ -1529,6 +1535,10 @@ move_cmd.parser.add_option(
u'-t', u'--timid', dest='timid', action='store_true',
help=u'always confirm all actions'
)
move_cmd.parser.add_option(
u'-e', u'--export', default=False, action='store_true',
help=u'copy without changing the database path'
)
move_cmd.parser.add_album_option()
move_cmd.func = move_func
default_commands.append(move_cmd)

View file

@ -174,6 +174,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
genre tree.
"""
depth_tag_pairs = [(self._get_depth(t), t) for t in tags]
depth_tag_pairs = [e for e in depth_tag_pairs if e[0] is not None]
depth_tag_pairs.sort(reverse=True)
return [p[1] for p in depth_tag_pairs]

View file

@ -80,7 +80,7 @@ class MusicBrainzCollectionPlugin(BeetsPlugin):
self.update_album_list([task.album])
def update_album_list(self, album_list):
"""Update the MusicBrainz colleciton from a list of Beets albums
"""Update the MusicBrainz collection from a list of Beets albums
"""
# Get the available collections.
collections = mb_call(musicbrainzngs.get_collections)

View file

@ -805,6 +805,7 @@ class ReplayGainPlugin(BeetsPlugin):
'auto': True,
'backend': u'command',
'targetlevel': 89,
'r128': ['Opus'],
})
self.overwrite = self.config['overwrite'].get(bool)
@ -822,6 +823,9 @@ class ReplayGainPlugin(BeetsPlugin):
if self.config['auto']:
self.import_stages = [self.imported]
# Formats to use R128.
self.r128_whitelist = self.config['r128'].as_str_seq()
try:
self.backend_instance = self.backends[backend_name](
self.config, self._log
@ -830,9 +834,19 @@ class ReplayGainPlugin(BeetsPlugin):
raise ui.UserError(
u'replaygain initialization failed: {0}'.format(e))
self.r128_backend_instance = ''
def should_use_r128(self, item):
"""Checks the plugin setting to decide whether the calculation
should be done using the EBU R128 standard and use R128_ tags instead.
"""
return item.format in self.r128_whitelist
def track_requires_gain(self, item):
return self.overwrite or \
(not item.rg_track_gain or not item.rg_track_peak)
(self.should_use_r128(item) and not item.r128_track_gain) or \
(not self.should_use_r128(item) and
(not item.rg_track_gain or not item.rg_track_peak))
def album_requires_gain(self, album):
# Skip calculating gain only when *all* files don't need
@ -840,8 +854,12 @@ class ReplayGainPlugin(BeetsPlugin):
# needs recalculation, we still get an accurate album gain
# value.
return self.overwrite or \
any([not item.rg_album_gain or not item.rg_album_peak
for item in album.items()])
any([self.should_use_r128(item) and
(not item.r128_item_gain or not item.r128_album_gain)
for item in album.items()]) or \
any([not self.should_use_r128(item) and
(not item.rg_album_gain or not item.rg_album_peak)
for item in album.items()])
def store_track_gain(self, item, track_gain):
item.rg_track_gain = track_gain.gain
@ -851,6 +869,12 @@ class ReplayGainPlugin(BeetsPlugin):
self._log.debug(u'applied track gain {0}, peak {1}',
item.rg_track_gain, item.rg_track_peak)
def store_track_r128_gain(self, item, track_gain):
item.r128_track_gain = int(round(track_gain.gain * pow(2, 8)))
item.store()
self._log.debug(u'applied track gain {0}', item.r128_track_gain)
def store_album_gain(self, album, album_gain):
album.rg_album_gain = album_gain.gain
album.rg_album_peak = album_gain.peak
@ -859,6 +883,12 @@ class ReplayGainPlugin(BeetsPlugin):
self._log.debug(u'applied album gain {0}, peak {1}',
album.rg_album_gain, album.rg_album_peak)
def store_album_r128_gain(self, album, album_gain):
album.r128_album_gain = int(round(album_gain.gain * pow(2, 8)))
album.store()
self._log.debug(u'applied album gain {0}', album.r128_album_gain)
def handle_album(self, album, write):
"""Compute album and track replay gain store it in all of the
album's items.
@ -873,17 +903,35 @@ class ReplayGainPlugin(BeetsPlugin):
self._log.info(u'analyzing {0}', album)
if (any([self.should_use_r128(item) for item in album.items()]) and
all(([self.should_use_r128(item) for item in album.items()]))):
raise ReplayGainError(
u"Mix of ReplayGain and EBU R128 detected"
u"for some tracks in album {0}".format(album)
)
if any([self.should_use_r128(item) for item in album.items()]):
if self.r128_backend_instance == '':
self.init_r128_backend()
backend_instance = self.r128_backend_instance
store_track_gain = self.store_track_r128_gain
store_album_gain = self.store_album_r128_gain
else:
backend_instance = self.backend_instance
store_track_gain = self.store_track_gain
store_album_gain = self.store_album_gain
try:
album_gain = self.backend_instance.compute_album_gain(album)
album_gain = backend_instance.compute_album_gain(album)
if len(album_gain.track_gains) != len(album.items()):
raise ReplayGainError(
u"ReplayGain backend failed "
u"for some tracks in album {0}".format(album)
)
self.store_album_gain(album, album_gain.album_gain)
store_album_gain(album, album_gain.album_gain)
for item, track_gain in zip(album.items(), album_gain.track_gains):
self.store_track_gain(item, track_gain)
store_track_gain(item, track_gain)
if write:
item.try_write()
except ReplayGainError as e:
@ -905,14 +953,23 @@ class ReplayGainPlugin(BeetsPlugin):
self._log.info(u'analyzing {0}', item)
if self.should_use_r128(item):
if self.r128_backend_instance == '':
self.init_r128_backend()
backend_instance = self.r128_backend_instance
store_track_gain = self.store_track_r128_gain
else:
backend_instance = self.backend_instance
store_track_gain = self.store_track_gain
try:
track_gains = self.backend_instance.compute_track_gain([item])
track_gains = backend_instance.compute_track_gain([item])
if len(track_gains) != 1:
raise ReplayGainError(
u"ReplayGain backend failed for track {0}".format(item)
)
self.store_track_gain(item, track_gains[0])
store_track_gain(item, track_gains[0])
if write:
item.try_write()
except ReplayGainError as e:
@ -921,6 +978,19 @@ class ReplayGainPlugin(BeetsPlugin):
raise ui.UserError(
u"Fatal replay gain error: {0}".format(e))
def init_r128_backend(self):
backend_name = 'bs1770gain'
try:
self.r128_backend_instance = self.backends[backend_name](
self.config, self._log
)
except (ReplayGainError, FatalReplayGainError) as e:
raise ui.UserError(
u'replaygain initialization failed: {0}'.format(e))
self.r128_backend_instance.method = '--ebu'
def imported(self, session, task):
"""Add replay gain info to items or albums of ``task``.
"""

View file

@ -1,42 +1,76 @@
Changelog
=========
1.4.4 (in development)
1.4.5 (in development)
----------------------
New features:
Features:
* :ref:`Date queries <datequery>` can now include times, so you can filter
your music down to the second. Thanks to :user:`discopatrick`. :bug:`2506`
:bug:`2528`
* :doc:`/plugins/replaygain`: We now keep R128 data in separate tags from
classic ReplayGain data for formats that need it (namely, Ogg Opus). A new
`r128` configuration option enables this behavior for specific formats.
Thanks to :user:`autrimpo`. :bug:`2557` :bug:`2560`
* The :ref:`move-cmd` command gained a new ``--export`` flag, which copies
files to an external location without changing their location in the library
database. Thanks to :user:`SpirosChadoulos`. :bug:`435` :bug:`2510`
Fixes:
* :doc:`/plugins/lastgenre`: Fix a crash when using the `prefer_specific` and
`canonical` options together. Thanks to :user:`yacoob`. :bug:`2459`
:bug:`2583`
1.4.4 (June 10, 2017)
---------------------
This release built up a longer-than-normal list of nifty new features. We now
support DSF audio files and the importer can hard-link your files, for
example.
Here's a full list of new features:
* Added support for DSF files, once a future version of Mutagen is released
that supports them. Thanks to :user:`docbobo`. :bug:`459` :bug:`2379`
* A new :ref:`hardlink` config option instructs the importer to create hard
links on filesystems that support them. Thanks to :user:`jacobwgillespie`.
:bug:`2445`
* A new :doc:`/plugins/kodiupdate` lets you keep your Kodi library in sync
with beets. Thanks to :user:`Pauligrinder`. :bug:`2411`
* A new :ref:`bell` configuration option under the ``import`` section enables
a terminal bell when input is required. Thanks to :user:`SpirosChadoulos`.
:bug:`2366` :bug:`2495`
* A new field, ``composer_sort``, is now supported and fetched from
MusicBrainz.
Thanks to :user:`dosoe`.
:bug:`2519` :bug:`2529`
* The MusicBrainz backend and :doc:`/plugins/discogs` now both provide a new
attribute called ``track_alt`` that stores more nuanced, possibly
non-numeric track index data. For example, some vinyl or tape media will
report the side of the record using a letter instead of a number in that
field. :bug:`1831` :bug:`2363`
* The :doc:`/plugins/web` has a new endpoint, ``/item/path/foo``, which will
* :doc:`/plugins/web`: Added a new endpoint, ``/item/path/foo``, which will
return the item info for the file at the given path, or 404.
* The :doc:`/plugins/web` also has a new config option, ``include_paths``,
* :doc:`/plugins/web`: Added a new config option, ``include_paths``,
which will cause paths to be included in item API responses if set to true.
* The ``%aunique`` template function for :ref:`aunique` now takes a third
argument that specifies which brackets to use around the disambiguator
argument that specifies which brackets to use around the disambiguator
value. The argument can be any two characters that represent the left and
right brackets. It defaults to `[]` and can also be blank to turn off
bracketing. :bug:`2397` :bug:`2399`
* Added a ``--move`` or ``-m`` option to the importer so that the files can be
moved to the library instead of being copied or added "in place".
moved to the library instead of being copied or added "in place."
:bug:`2252` :bug:`2429`
* :doc:`/plugins/badfiles`: Added a ``--verbose`` or ``-v`` option. Results are
now displayed only for corrupted files by default and for all the files when
the verbose option is set. :bug:`1654` :bug:`2434`
* A new :ref:`hardlink` config option instructs the importer to create hard
links on filesystems that support them. Thanks to :user:`jacobwgillespie`.
:bug:`2445`
* :doc:`/plugins/embedart`: The explicit ``embedart`` command now asks for
confirmation before embedding art into music files. Thanks to
:user:`Stunner`. :bug:`1999`
* You can now run beets by typing `python -m beets`. :bug:`2453`
* A new :doc:`/plugins/kodiupdate` lets you keep your Kodi library in sync
with beets. Thanks to :user:`Pauligrinder`. :bug:`2411`
* :doc:`/plugins/smartplaylist`: Different playlist specifications that
generate identically-named playlist files no longer conflict; instead, the
resulting lists of tracks are concatenated. :bug:`2468`
@ -44,9 +78,6 @@ New features:
you have in your library. Thanks to :user:`qlyoung`. :bug:`2481`
* :doc:`/plugins/web` : Add new `reverse_proxy` config option to allow serving
the web plugins under a reverse proxy.
* A new :ref:`bell` configuration option under the ``import`` section enables
a terminal bell when input is required. Thanks to :user:`SpirosChadoulos`.
:bug:`2366` :bug:`2495`
* Importing a release with multiple release events now selects the
event based on your :ref:`preferred` countries. :bug:`2501`
* :doc:`/plugins/play`: A new ``-y`` or ``--yes`` parameter lets you skip
@ -66,24 +97,24 @@ New features:
passing one or more `--set field=value` options on the command-line.
:bug: `1881`
Fixes:
There are also quite a few fixes:
* In the :ref:`replace` configuration option, we now replace a leading hyphen
(-) with an underscore. :bug:`549` :bug:`2509`
* :doc:`/plugins/absubmit`: Do not filter for supported formats. :bug:`2471`
* :doc:`/plugins/absubmit`: We no longer filter audio files for specific
formats---we will attempt the submission process for all formats. :bug:`2471`
* :doc:`/plugins/mpdupdate`: Fix Python 3 compatibility. :bug:`2381`
* :doc:`/plugins/replaygain`: Fix Python 3 compatibility in the ``bs1770gain``
backend. :bug:`2382`
* :doc:`/plugins/bpd`: Report playback times as integer. :bug:`2394`
* :doc:`/plugins/bpd`: Report playback times as integers. :bug:`2394`
* :doc:`/plugins/mpdstats`: Fix Python 3 compatibility. The plugin also now
requires version 0.4.2 or later of the ``python-mpd2`` library. :bug:`2405`
* :doc:`/plugins/mpdstats`: Improve handling of mpd status queries.
* :doc:`/plugins/mpdstats`: Improve handling of MPD status queries.
* :doc:`/plugins/badfiles`: Fix Python 3 compatibility.
* Fix some cases where album-level ReplayGain/SoundCheck metadata would be
written to files incorrectly. :bug:`2426`
* :doc:`/plugins/badfiles`: The command no longer bails out if validator
command is not found or exists with an error. :bug:`2430` :bug:`2433`
* :doc:`/plugins/badfiles`: The command no longer bails out if the validator
command is not found or exits with an error. :bug:`2430` :bug:`2433`
* :doc:`/plugins/lyrics`: The Google search backend no longer crashes when the
server responds with an error. :bug:`2437`
* :doc:`/plugins/discogs`: You can now authenticate with Discogs using a
@ -92,7 +123,7 @@ Fixes:
Thanks to :user:`Lompik`. :bug:`2443` :bug:`2448`
* :doc:`/plugins/duplicates`: Fix Python 3 compatibility when using the
``copy`` and ``move`` options. :bug:`2444`
* :doc:`/plugins/mbsubmit`: The tracks are now sorted. Thanks to
* :doc:`/plugins/mbsubmit`: The tracks are now sorted properly. Thanks to
:user:`awesomer`. :bug:`2457`
* :doc:`/plugins/thumbnails`: Fix a string-related crash on Python 3.
:bug:`2466`
@ -103,8 +134,8 @@ Fixes:
limited encoding.
* :doc:`/plugins/convert`: The default configuration uses FFmpeg's built-in
AAC codec instead of faac. Thanks to :user:`jansol`. :bug:`2484`
* Fix import of multidisc releases with subdirectories, which previously
made each disc be imported separately in different releases. :bug:`2493`
* Fix the importer's detection of multi-disc albums when other subdirectories
are present. :bug:`2493`
* Invalid date queries now print an error message instead of being silently
ignored. Thanks to :user:`discopatrick`. :bug:`2513` :bug:`2517`
* When the SQLite database stops being accessible, we now print a friendly
@ -115,7 +146,7 @@ Fixes:
* Fix a crash when reading non-ASCII characters in configuration files on
Windows under Python 3. :bug:`2456` :bug:`2565` :bug:`2566`
Two plugins had backends removed due to bitrot:
We removed backends from two metadata plugins because of bitrot:
* :doc:`/plugins/lyrics`: The Lyrics.com backend has been removed. (It stopped
working because of changes to the site's URL structure.)

View file

@ -16,7 +16,7 @@ project = u'beets'
copyright = u'2016, Adrian Sampson'
version = '1.4'
release = '1.4.4'
release = '1.4.5'
pygments_style = 'sphinx'

View file

@ -140,3 +140,31 @@ and the given command is used for all conversions.
convert:
command: ffmpeg -i $source -y -vn -aq 2 $dest
extension: mp3
Gapless MP3 encoding
````````````````````
While FFmpeg cannot produce "`gapless`_" MP3s by itself, you can create them
by using `LAME`_ directly. Use a shell script like this to pipe the output of
FFmpeg into the LAME tool::
#!/bin/sh
ffmpeg -i "$1" -f wav - | lame -V 2 --noreplaygain - "$2"
Then configure the ``convert`` plugin to use the script::
convert:
command: /path/to/script.sh $source $dest
extension: mp3
This strategy configures FFmpeg to produce a WAV file with an accurate length
header for LAME to use. Using ``--noreplaygain`` disables gain analysis; you
can use the :doc:`/plugins/replaygain` to do this analysis. See the LAME
`documentation`_ and the `HydrogenAudio wiki`_ for other LAME configuration
options and a thorough discussion of MP3 encoding.
.. _documentation: http://lame.sourceforge.net/using.php
.. _HydrogenAudio wiki: http://wiki.hydrogenaud.io/index.php?title=LAME
.. _gapless: http://wiki.hydrogenaud.io/index.php?title=Gapless_playback
.. _LAME: http://lame.sourceforge.net/

View file

@ -108,6 +108,10 @@ configuration file. The available options are:
Default: ``no``.
- **targetlevel**: A number of decibels for the target loudness level.
Default: 89.
- **r128**: A space separated list of formats that will use ``R128_`` tags with
integer values instead of the common ``REPLAYGAIN_`` tags with floating point
values. Requires the "bs1770gain" backend.
Default: ``Opus``.
These options only work with the "command" backend:

View file

@ -285,6 +285,7 @@ query are renamed into your library directory structure. By specifying a
destination directory with ``-d`` manually, you can move items matching a query
anywhere in your filesystem. The ``-c`` option copies files instead of moving
them. As with other commands, the ``-a`` option matches albums instead of items.
The ``-e`` flag (for "export") copies files without changing the database.
To perform a "dry run", just use the ``-p`` (for "pretend") flag. This will
show you a list of files that would be moved but won't actually change anything

View file

@ -188,6 +188,35 @@ Find all items with a file modification time between 2008-12-01 and
$ beet ls 'mtime:2008-12-01..2008-12-02'
You can also add an optional time value to date queries, specifying hours,
minutes, and seconds.
Times are separated from dates by a space, an uppercase 'T' or a lowercase
't', for example: ``2008-12-01T23:59:59``. If you specify a time, then the
date must contain a year, month, and day. The minutes and seconds are
optional.
Here is an example that finds all items added on 2008-12-01 at or after 22:00
but before 23:00::
$ beet ls 'added:2008-12-01T22'
To find all items added on or after 2008-12-01 at 22:45::
$ beet ls 'added:2008-12-01T22:45..'
To find all items added on 2008-12-01, at or after 22:45:20 but before
22:45:41::
$ beet ls 'added:2008-12-01T22:45:20..2008-12-01T22:45:40'
Here are example of the three ways to separate dates from times. All of these
queries do the same thing::
$ beet ls 'added:2008-12-01T22:45:20'
$ beet ls 'added:2008-12-01t22:45:20'
$ beet ls 'added:2008-12-01 22:45:20'
.. _not_query:
Query Term Negation

View file

@ -56,7 +56,7 @@ if 'sdist' in sys.argv:
setup(
name='beets',
version='1.4.4',
version='1.4.5',
description='music tagger and library organizer',
author='Adrian Sampson',
author_email='adrian@radbox.org',

View file

@ -58,6 +58,51 @@ class DateIntervalTest(unittest.TestCase):
self.assertExcludes('1999-12..2000-02', '1999-11-30T23:59:59')
self.assertExcludes('1999-12..2000-02', '2000-03-01T00:00:00')
def test_hour_precision_intervals(self):
# test with 'T' separator
self.assertExcludes('2000-01-01T12..2000-01-01T13',
'2000-01-01T11:59:59')
self.assertContains('2000-01-01T12..2000-01-01T13',
'2000-01-01T12:00:00')
self.assertContains('2000-01-01T12..2000-01-01T13',
'2000-01-01T12:30:00')
self.assertContains('2000-01-01T12..2000-01-01T13',
'2000-01-01T13:30:00')
self.assertContains('2000-01-01T12..2000-01-01T13',
'2000-01-01T13:59:59')
self.assertExcludes('2000-01-01T12..2000-01-01T13',
'2000-01-01T14:00:00')
self.assertExcludes('2000-01-01T12..2000-01-01T13',
'2000-01-01T14:30:00')
# test non-range query
self.assertContains('2008-12-01T22',
'2008-12-01T22:30:00')
self.assertExcludes('2008-12-01T22',
'2008-12-01T23:30:00')
def test_minute_precision_intervals(self):
self.assertExcludes('2000-01-01T12:30..2000-01-01T12:31',
'2000-01-01T12:29:59')
self.assertContains('2000-01-01T12:30..2000-01-01T12:31',
'2000-01-01T12:30:00')
self.assertContains('2000-01-01T12:30..2000-01-01T12:31',
'2000-01-01T12:30:30')
self.assertContains('2000-01-01T12:30..2000-01-01T12:31',
'2000-01-01T12:31:59')
self.assertExcludes('2000-01-01T12:30..2000-01-01T12:31',
'2000-01-01T12:32:00')
def test_second_precision_intervals(self):
self.assertExcludes('2000-01-01T12:30:50..2000-01-01T12:30:55',
'2000-01-01T12:30:49')
self.assertContains('2000-01-01T12:30:50..2000-01-01T12:30:55',
'2000-01-01T12:30:50')
self.assertContains('2000-01-01T12:30:50..2000-01-01T12:30:55',
'2000-01-01T12:30:55')
self.assertExcludes('2000-01-01T12:30:50..2000-01-01T12:30:55',
'2000-01-01T12:30:56')
def test_unbounded_endpoints(self):
self.assertContains('..', date=datetime.max)
self.assertContains('..', date=datetime.min)
@ -140,6 +185,25 @@ class DateQueryConstructTest(unittest.TestCase):
with self.assertRaises(InvalidQueryArgumentValueError):
DateQuery('added', q)
def test_datetime_uppercase_t_separator(self):
date_query = DateQuery('added', '2000-01-01T12')
self.assertEqual(date_query.interval.start, datetime(2000, 1, 1, 12))
self.assertEqual(date_query.interval.end, datetime(2000, 1, 1, 13))
def test_datetime_lowercase_t_separator(self):
date_query = DateQuery('added', '2000-01-01t12')
self.assertEqual(date_query.interval.start, datetime(2000, 1, 1, 12))
self.assertEqual(date_query.interval.end, datetime(2000, 1, 1, 13))
def test_datetime_space_separator(self):
date_query = DateQuery('added', '2000-01-01 12')
self.assertEqual(date_query.interval.start, datetime(2000, 1, 1, 12))
self.assertEqual(date_query.interval.end, datetime(2000, 1, 1, 13))
def test_datetime_invalid_separator(self):
with self.assertRaises(InvalidQueryArgumentValueError):
DateQuery('added', '2000-01-01x12')
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)

View file

@ -213,6 +213,18 @@ class LastGenrePluginTest(unittest.TestCase, TestHelper):
self.assertEqual(res, (config['lastgenre']['fallback'].get(),
u'fallback'))
def test_sort_by_depth(self):
self._setup_config(canonical=True)
# Normal case.
tags = ('electronic', 'ambient', 'post-rock', 'downtempo')
res = self.plugin._sort_by_depth(tags)
self.assertEqual(
res, ['post-rock', 'downtempo', 'ambient', 'electronic'])
# Non-canonical tag ('chillout') present.
tags = ('electronic', 'ambient', 'chillout')
res = self.plugin._sort_by_depth(tags)
self.assertEqual(res, ['ambient', 'electronic'])
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)

View file

@ -374,6 +374,8 @@ class ReadWriteTestBase(ArtTestMixin, GenreListTestMixin,
'rg_track_gain',
'rg_album_peak',
'rg_album_gain',
'r128_track_gain',
'r128_album_gain',
'albumartist',
'mb_albumartistid',
'artist_sort',
@ -672,6 +674,9 @@ class ReadWriteTestBase(ArtTestMixin, GenreListTestMixin,
if key.startswith('rg_'):
# ReplayGain is float
tags[key] = 1.0
elif key.startswith('r128_'):
# R128 is int
tags[key] = -1
else:
tags[key] = 'value\u2010%s' % key

View file

@ -421,8 +421,9 @@ class MoveTest(_common.TestCase):
self.otherdir = os.path.join(self.temp_dir, b'testotherdir')
def _move(self, query=(), dest=None, copy=False, album=False,
pretend=False):
commands.move_items(self.lib, dest, query, copy, album, pretend)
pretend=False, export=False):
commands.move_items(self.lib, dest, query, copy, album, pretend,
export=export)
def test_move_item(self):
self._move()
@ -476,6 +477,24 @@ class MoveTest(_common.TestCase):
self.i.load()
self.assertIn(b'srcfile', self.i.path)
def test_export_item_custom_dir(self):
self._move(dest=self.otherdir, export=True)
self.i.load()
self.assertEqual(self.i.path, self.itempath)
self.assertExists(self.otherdir)
def test_export_album_custom_dir(self):
self._move(dest=self.otherdir, album=True, export=True)
self.i.load()
self.assertEqual(self.i.path, self.itempath)
self.assertExists(self.otherdir)
def test_pretend_export_item(self):
self._move(dest=self.otherdir, pretend=True, export=True)
self.i.load()
self.assertIn(b'srcfile', self.i.path)
self.assertNotExists(self.otherdir)
class UpdateTest(_common.TestCase):
def setUp(self):