mirror of
https://github.com/beetbox/beets.git
synced 2025-12-26 10:34:09 +01:00
Merge branch 'master' into master
This commit is contained in:
commit
2eb4e3d515
20 changed files with 402 additions and 57 deletions
|
|
@ -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>'
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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``.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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.)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2
setup.py
2
setup.py
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in a new issue