Merge branch 'master' of https://github.com/sampsyo/beets into spotify-plugin

This commit is contained in:
Olin Gay 2014-08-17 08:36:32 -04:00
commit 6bab9a2cae
18 changed files with 582 additions and 194 deletions

View file

@ -52,16 +52,19 @@ def albums_in_dir(path):
for filename in files:
try:
i = library.Item.from_path(os.path.join(root, filename))
except mediafile.FileTypeError:
pass
except mediafile.UnreadableFileError:
log.warn(u'unreadable file: {0}'.format(
displayable_path(filename))
)
except library.ReadError as exc:
log.error(u'error reading {0}: {1}'.format(
displayable_path(filename), exc
))
if isinstance(exc.reason, mediafile.FileTypeError):
# Silently ignore non-music files.
pass
elif isinstance(exc.reason, mediafile.UnreadableFileError):
log.warn(u'unreadable file: {0}'.format(
displayable_path(filename))
)
else:
log.error(u'error reading {0}: {1}'.format(
displayable_path(filename),
exc,
))
else:
items.append(i)

View file

@ -32,6 +32,7 @@ replace:
'\s+$': ''
'^\s+': ''
path_sep_replace: _
asciify_paths: false
art_filename: cover
max_filename_length: 0

View file

@ -393,7 +393,8 @@ class Item(LibModel):
else:
path = normpath(path)
try:
mediafile = MediaFile(syspath(path))
mediafile = MediaFile(syspath(path),
id3v23=beets.config['id3v23'].get(bool))
except (OSError, IOError) as exc:
raise ReadError(self.path, exc)
@ -401,7 +402,7 @@ class Item(LibModel):
mediafile.update(self)
try:
mediafile.save(id3v23=beets.config['id3v23'].get(bool))
mediafile.save()
except (OSError, IOError, MutagenError) as exc:
raise WriteError(self.path, exc)
@ -571,8 +572,13 @@ class Item(LibModel):
subpath = unicodedata.normalize('NFD', subpath)
else:
subpath = unicodedata.normalize('NFC', subpath)
if beets.config['asciify_paths']:
subpath = unidecode(subpath)
# Truncate components and remove forbidden characters.
subpath = util.sanitize_path(subpath, self._db.replacements)
# Encode for the filesystem.
if not fragment:
subpath = bytestring_path(subpath)
@ -830,6 +836,8 @@ class Album(LibModel):
filename_tmpl = Template(beets.config['art_filename'].get(unicode))
subpath = self.evaluate_template(filename_tmpl, True)
if beets.config['asciify_paths']:
subpath = unidecode(subpath)
subpath = util.sanitize_path(subpath,
replacements=self._db.replacements)
subpath = bytestring_path(subpath)

View file

@ -1222,9 +1222,12 @@ class MediaFile(object):
"""Represents a multimedia file on disk and provides access to its
metadata.
"""
def __init__(self, path):
def __init__(self, path, id3v23=False):
"""Constructs a new `MediaFile` reflecting the file at path. May
throw `UnreadableFileError`.
By default, MP3 files are saved with ID3v2.4 tags. You can use
the older ID3v2.3 standard by specifying the `id3v23` option.
"""
self.path = path
@ -1296,18 +1299,19 @@ class MediaFile(object):
else:
raise FileTypeError(path, type(self.mgfile).__name__)
# add a set of tags if it's missing
# Add a set of tags if it's missing.
if self.mgfile.tags is None:
self.mgfile.add_tags()
def save(self, id3v23=False):
"""Write the object's tags back to the file.
# Set the ID3v2.3 flag only for MP3s.
self.id3v23 = id3v23 and self.type == 'mp3'
By default, MP3 files are saved with ID3v2.4 tags. You can use
the older ID3v2.3 standard by specifying the `id3v23` option.
def save(self):
"""Write the object's tags back to the file.
"""
# Possibly save the tags to ID3v2.3.
kwargs = {}
if id3v23 and self.type == 'mp3':
if self.id3v23:
id3 = self.mgfile
if hasattr(id3, 'tags'):
# In case this is an MP3 object, not an ID3 object.

View file

@ -25,6 +25,7 @@ import pipes
from beets import ui, util, plugins, config
from beets.plugins import BeetsPlugin
from beetsplug.embedart import embed_item
from beets.util.confit import ConfigTypeError
log = logging.getLogger('beets')
_fs_lock = threading.Lock()
@ -37,65 +38,60 @@ ALIASES = {
}
def _destination(dest_dir, item, keep_new, path_formats):
"""Return the path under `dest_dir` where the file should be placed
(possibly after conversion).
def replace_ext(path, ext):
"""Return the path with its extension replaced by `ext`.
The new extension must not contain a leading dot.
"""
dest = item.destination(basedir=dest_dir, path_formats=path_formats)
if keep_new:
# When we're keeping the converted file, no extension munging
# occurs.
return dest
else:
# Otherwise, replace the extension.
_, ext = get_format()
return os.path.splitext(dest)[0] + ext
return os.path.splitext(path)[0] + '.' + ext
def get_format():
"""Get the currently configured format command and extension.
def get_format(format=None):
"""Return the command tempate and the extension from the config.
"""
format = config['convert']['format'].get(unicode).lower()
if not format:
format = config['convert']['format'].get(unicode).lower()
format = ALIASES.get(format, format)
format_info = config['convert']['formats'][format].get(dict)
# Convenience and backwards-compatibility shortcuts.
keys = config['convert'].keys()
if 'command' in keys:
format_info['command'] = config['convert']['command'].get(unicode)
elif 'opts' in keys:
# Undocumented option for backwards compatibility with < 1.3.1.
format_info['command'] = u'ffmpeg -i $source -y {0} $dest'.format(
config['convert']['opts'].get(unicode)
)
if 'extension' in keys:
format_info['extension'] = config['convert']['extension'].get(unicode)
try:
return (
format_info['command'].encode('utf8'),
(u'.' + format_info['extension']).encode('utf8'),
)
format_info = config['convert']['formats'][format].get(dict)
command = format_info['command']
extension = format_info['extension']
except KeyError:
raise ui.UserError(
u'convert: format {0} needs "command" and "extension" fields'
.format(format)
)
except ConfigTypeError:
command = config['convert']['formats'][format].get(str)
extension = format
# Convenience and backwards-compatibility shortcuts.
keys = config['convert'].keys()
if 'command' in keys:
command = config['convert']['command'].get(unicode)
elif 'opts' in keys:
# Undocumented option for backwards compatibility with < 1.3.1.
command = u'ffmpeg -i $source -y {0} $dest'.format(
config['convert']['opts'].get(unicode)
)
if 'extension' in keys:
extension = config['convert']['extension'].get(unicode)
return (command.encode('utf8'), extension.encode('utf8'))
def encode(source, dest):
"""Encode ``source`` to ``dest`` using the command from ``get_format()``.
def encode(command, source, dest, pretend=False):
"""Encode `source` to `dest` using command template `command`.
Raises an ``ui.UserError`` if the command was not found and a
``subprocess.CalledProcessError`` if the command exited with a
Raises `subprocess.CalledProcessError` if the command exited with a
non-zero status code.
"""
quiet = config['convert']['quiet'].get()
if not quiet:
log.info(u'Started encoding {0}'.format(util.displayable_path(source)))
if not quiet and not pretend:
log.info(u'Encoding {0}'.format(util.displayable_path(source)))
command, _ = get_format()
command = Template(command).safe_substitute({
'source': pipes.quote(source),
'dest': pipes.quote(dest),
@ -104,6 +100,10 @@ def encode(source, dest):
log.debug(u'convert: executing: {0}'
.format(util.displayable_path(command)))
if pretend:
log.info(command)
return
try:
util.command_output(command, shell=True)
except subprocess.CalledProcessError:
@ -115,10 +115,10 @@ def encode(source, dest):
raise
except OSError as exc:
raise ui.UserError(
u'convert: could invoke ffmpeg: {0}'.format(exc)
u"convert: could invoke '{0}': {0}".format(command, exc)
)
if not quiet:
if not quiet and not pretend:
log.info(u'Finished encoding {0}'.format(
util.displayable_path(source))
)
@ -134,10 +134,29 @@ def should_transcode(item):
item.bitrate >= 1000 * maxbr
def convert_item(dest_dir, keep_new, path_formats):
def convert_item(dest_dir, keep_new, path_formats, command, ext,
pretend=False):
while True:
item = yield
dest = _destination(dest_dir, item, keep_new, path_formats)
dest = item.destination(basedir=dest_dir, path_formats=path_formats)
# When keeping the new file in the library, we first move the
# current (pristine) file to the destination. We'll then copy it
# back to its old path or transcode it to a new path.
if keep_new:
original = dest
converted = replace_ext(item.path, ext)
else:
original = item.path
dest = replace_ext(dest, ext)
converted = dest
# Ensure that only one thread tries to create directories at a
# time. (The existence check is not atomic with the directory
# creation inside this function.)
if not pretend:
with _fs_lock:
util.mkdirall(dest)
if os.path.exists(util.syspath(dest)):
log.info(u'Skipping {0} (target file exists)'.format(
@ -145,36 +164,39 @@ def convert_item(dest_dir, keep_new, path_formats):
))
continue
# Ensure that only one thread tries to create directories at a
# time. (The existence check is not atomic with the directory
# creation inside this function.)
with _fs_lock:
util.mkdirall(dest)
# When keeping the new file in the library, we first move the
# current (pristine) file to the destination. We'll then copy it
# back to its old path or transcode it to a new path.
if keep_new:
log.info(u'Moving to {0}'.
format(util.displayable_path(dest)))
util.move(item.path, dest)
original = dest
_, ext = get_format()
converted = os.path.splitext(item.path)[0] + ext
else:
original = item.path
converted = dest
if pretend:
log.info(u'mv {0} {1}'.format(
util.displayable_path(item.path),
util.displayable_path(original),
))
else:
log.info(u'Moving to {0}'.format(
util.displayable_path(original))
)
util.move(item.path, original)
if not should_transcode(item):
# No transcoding necessary.
log.info(u'Copying {0}'.format(util.displayable_path(item.path)))
util.copy(original, converted)
if pretend:
log.info(u'cp {0} {1}'.format(
util.displayable_path(original),
util.displayable_path(converted),
))
else:
# No transcoding necessary.
log.info(u'Copying {0}'.format(
util.displayable_path(item.path))
)
util.copy(original, converted)
else:
try:
encode(original, converted)
encode(command, original, converted, pretend)
except subprocess.CalledProcessError:
continue
if pretend:
continue
# Write tags from the database to the converted file.
item.write(path=converted)
@ -198,12 +220,12 @@ def convert_on_import(lib, item):
library.
"""
if should_transcode(item):
_, ext = get_format()
command, ext = get_format()
fd, dest = tempfile.mkstemp(ext)
os.close(fd)
_temp_files.append(dest) # Delete the transcode later.
try:
encode(item.path, dest)
encode(command, item.path, dest)
except subprocess.CalledProcessError:
return
item.path = dest
@ -213,33 +235,45 @@ def convert_on_import(lib, item):
def convert_func(lib, opts, args):
dest = opts.dest if opts.dest is not None else \
config['convert']['dest'].get()
if not dest:
if not opts.dest:
opts.dest = config['convert']['dest'].get()
if not opts.dest:
raise ui.UserError('no convert destination set')
opts.dest = util.bytestring_path(opts.dest)
dest = util.bytestring_path(dest)
threads = opts.threads if opts.threads is not None else \
config['convert']['threads'].get(int)
keep_new = opts.keep_new
if not opts.threads:
opts.threads = config['convert']['threads'].get(int)
if not config['convert']['paths']:
path_formats = ui.get_path_formats()
else:
if config['convert']['paths']:
path_formats = ui.get_path_formats(config['convert']['paths'])
else:
path_formats = ui.get_path_formats()
ui.commands.list_items(lib, ui.decargs(args), opts.album, None)
if not opts.format:
opts.format = config['convert']['format'].get(unicode).lower()
if not ui.input_yn("Convert? (Y/n)"):
return
command, ext = get_format(opts.format)
pretend = opts.pretend if opts.pretend is not None else \
config['convert']['pretend'].get(bool)
if not pretend:
ui.commands.list_items(lib, ui.decargs(args), opts.album, None)
if not ui.input_yn("Convert? (Y/n)"):
return
if opts.album:
items = (i for a in lib.albums(ui.decargs(args)) for i in a.items())
else:
items = iter(lib.items(ui.decargs(args)))
convert = [convert_item(dest, keep_new, path_formats)
for i in range(threads)]
convert = [convert_item(opts.dest,
opts.keep_new,
path_formats,
command,
ext,
pretend)
for _ in range(opts.threads)]
pipe = util.pipeline.Pipeline([items, convert])
pipe.run_parallel()
@ -249,6 +283,7 @@ class ConvertPlugin(BeetsPlugin):
super(ConvertPlugin, self).__init__()
self.config.add({
u'dest': None,
u'pretend': False,
u'threads': util.cpu_count(),
u'format': u'mp3',
u'formats': {
@ -261,29 +296,14 @@ class ConvertPlugin(BeetsPlugin):
u'command': u'ffmpeg -i $source -y -vn -acodec alac $dest',
u'extension': u'm4a',
},
u'flac': {
u'command': u'ffmpeg -i $source -y -vn -acodec flac $dest',
u'extension': u'flac',
},
u'mp3': {
u'command': u'ffmpeg -i $source -y -vn -aq 2 $dest',
u'extension': u'mp3',
},
u'opus': {
u'command': u'ffmpeg -i $source -y -vn -acodec libopus '
u'-ab 96k $dest',
u'extension': u'opus',
},
u'ogg': {
u'command': u'ffmpeg -i $source -y -vn -acodec libvorbis '
u'-aq 2 $dest',
u'extension': u'ogg',
},
u'windows media': {
u'command': u'ffmpeg -i $source -y -vn -acodec wmav2 '
u'-vn $dest',
u'extension': u'wma',
},
u'flac': u'ffmpeg -i $source -y -vn -acodec flac $dest',
u'mp3': u'ffmpeg -i $source -y -vn -aq 2 $dest',
u'opus':
u'ffmpeg -i $source -y -vn -acodec libopus -ab 96k $dest',
u'ogg':
u'ffmpeg -i $source -y -vn -acodec libvorbis -aq 2 $dest',
u'wma':
u'ffmpeg -i $source -y -vn -acodec wmav2 -vn $dest',
},
u'max_bitrate': 500,
u'auto': False,
@ -295,6 +315,8 @@ class ConvertPlugin(BeetsPlugin):
def commands(self):
cmd = ui.Subcommand('convert', help='convert to external location')
cmd.parser.add_option('-p', '--pretend', action='store_true',
help='show actions but do nothing')
cmd.parser.add_option('-a', '--album', action='store_true',
help='choose albums instead of tracks')
cmd.parser.add_option('-t', '--threads', action='store', type='int',
@ -305,6 +327,8 @@ class ConvertPlugin(BeetsPlugin):
and move the old files')
cmd.parser.add_option('-d', '--dest', action='store',
help='set the destination directory')
cmd.parser.add_option('-f', '--format', action='store', dest='format',
help='set the destination directory')
cmd.func = convert_func
return [cmd]

View file

@ -137,6 +137,9 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin):
except pyechonest.util.EchoNestAPIError as e:
if e.code == 3:
# reached access limit per minute
log.debug(u'echonest: rate-limited on try {0}; '
u'waiting {1} seconds'
.format(i + 1, RETRY_INTERVAL))
time.sleep(RETRY_INTERVAL)
elif e.code == 5:
# specified identifier does not exist

View file

@ -183,11 +183,12 @@ def clear(lib, query):
for item in lib.items(query):
log.info(u'%s - %s' % (item.artist, item.title))
try:
mf = mediafile.MediaFile(syspath(item.path))
mf = mediafile.MediaFile(syspath(item.path),
config['id3v23'].get(bool))
except mediafile.UnreadableFileError as exc:
log.error(u'Could not clear art from {0}: {1}'.format(
displayable_path(item.path), exc
))
continue
mf.art = None
mf.save(config['id3v23'].get(bool))
mf.save()

View file

@ -68,7 +68,8 @@ class ScrubPlugin(BeetsPlugin):
# Get album art if we need to restore it.
if opts.write:
mf = mediafile.MediaFile(item.path)
mf = mediafile.MediaFile(item.path,
config['id3v23'].get(bool))
art = mf.art
# Remove all tags.
@ -82,7 +83,7 @@ class ScrubPlugin(BeetsPlugin):
log.info('restoring art')
mf = mediafile.MediaFile(item.path)
mf.art = art
mf.save(config['id3v23'].get(bool))
mf.save()
scrubbing = False

View file

@ -12,7 +12,8 @@ New stuff
IFF chunks.
* A new :ref:`required` configuration option for the importer skips matches
that are missing certain data. Thanks to oprietop.
* The new :doc:`/plugins/bpm` lets you manually measure the tempo of a playing song. Thanks to aroquen.
* The new :doc:`/plugins/bpm` lets you manually measure the tempo of a playing
song. Thanks to aroquen.
Little improvements and fixes:
@ -67,6 +68,16 @@ Little improvements and fixes:
import.
* :doc:`/plugins/chroma`: A new ``auto`` configuration option disables
fingerprinting on import. Thanks to ddettrittus.
* :doc:`/plugins/convert`: A new ``--format`` option to can select the
transcoding preset from the command-line.
* :doc:`/plugins/convert`: Transcoding presets can now omit their filename
extensions (extensions default to the name of the preset).
* A new :ref:`asciify-paths` configuration option replaces all non-ASCII
characters in paths.
* :doc:`/plugins/convert`: A new ``--pretend`` option lets you preview the
commands the plugin will execute without actually taking any action. Thanks
to Dietrich Daroch.
1.3.6 (May 10, 2014)

View file

@ -4,23 +4,17 @@ Convert Plugin
The ``convert`` plugin lets you convert parts of your collection to a
directory of your choice, transcoding audio and embedding album art along the
way. It can transcode to and from any format using a configurable command
line. It will skip files that are already present in the target directory.
Converted files follow the same path formats as your library.
.. _FFmpeg: http://ffmpeg.org
line.
Installation
------------
First, enable the ``convert`` plugin (see :doc:`/plugins/index`).
Enable the ``convert`` plugin in your configuration (see
:doc:`/plugins/index`). By default, the plugin depends on `FFmpeg`_ to
transcode the audio, so you might want to install it.
To transcode music, this plugin requires the ``ffmpeg`` command-line
tool. If its executable is in your path, it will be found automatically
by the plugin. Otherwise, configure the plugin to locate the executable::
convert:
ffmpeg: /usr/bin/ffmpeg
.. _FFmpeg: http://ffmpeg.org
Usage
@ -28,17 +22,29 @@ Usage
To convert a part of your collection, run ``beet convert QUERY``. This
will display all items matching ``QUERY`` and ask you for confirmation before
starting the conversion. The ``-a`` (or ``--album``) option causes the command
to match albums instead of tracks.
starting the conversion. The command will then transcode all the
matching files to the destination directory given by the ``-d``
(``--dest``) option or the ``dest`` configuration. The path layout
mirrors that of your library, but it may be customized through the
``paths`` configuration.
The ``-t`` (``--threads``) and ``-d`` (``--dest``) options allow you to specify
or overwrite the respective configuration options.
The plugin uses a command-line program to transcode the audio. With the
``-f`` (``--format``) option you can choose the transcoding command
and customize the available commands
:ref:`through the configuration <convert-format-config>`.
The ``-a`` (or ``--album``) option causes the command
to match albums instead of tracks.
By default, the command places converted files into the destination directory
and leaves your library pristine. To instead back up your original files into
the destination directory and keep converted files in your library, use the
``-k`` (or ``--keep-new``) option.
To test your configuration without taking any actions, use the ``--pretend``
flag. The plugin will print out the commands it will run instead of executing
them.
Configuration
-------------
@ -48,7 +54,7 @@ The plugin offers several configuration options, all of which live under the
* ``dest`` sets the directory the files will be converted (or copied) to.
A destination is required---you either have to provide it in the config file
or on the command line using the ``-d`` flag.
or on the command-line using the ``-d`` flag.
* ``embed`` indicates whether or not to embed album art in converted items.
Default: true.
* If you set ``max_bitrate``, all lossy files with a higher bitrate will be
@ -69,39 +75,14 @@ The plugin offers several configuration options, all of which live under the
encoding. By default, the plugin will detect the number of processors
available and use them all.
These config options control the transcoding process:
.. _convert-format-config:
* ``format`` is the name of the audio file format to transcode to. Files that
are already in the format (and are below the maximum bitrate) will not be
transcoded. The plugin includes default commands for the formats MP3, AAC,
ALAC, FLAC, Opus, Vorbis, and Windows Media; the default is MP3. If you want
to use a different format (or customize the transcoding options), use the
options below.
* ``extension`` is the filename extension to be used for newly transcoded
files. This is implied by the ``format`` option, but you can set it yourself
if you're using a different format.
* ``command`` is the command line to use to transcode audio. A default
command, usually using an FFmpeg invocation, is implied by the ``format``
option. The tokens ``$source`` and ``$dest`` in the command are replaced
with the paths to the existing and new file. For example, the command
``ffmpeg -i $source -y -aq 4 $dest`` transcodes to MP3 using FFmpeg at the
V4 quality level.
Configuring the transcoding command
```````````````````````````````````
Here's an example configuration::
convert:
embed: false
format: aac
max_bitrate: 200
dest: /home/user/MusicForPhone
threads: 4
paths:
default: $albumartist/$title
If you have several formats you want to switch between, you can list them
under the ``formats`` key and refer to them using the ``format`` option. Each
key under ``formats`` should contain values for ``command`` and ``extension``
as described above::
You can customize the transcoding command through the ``formats`` map
and select a command with the ``--format`` command-line option or the
``format`` configuration.::
convert:
format: speex
@ -109,6 +90,28 @@ as described above::
speex:
command: ffmpeg -i $source -y -acodec speex $dest
extension: spx
wav:
command: ffmpeg -i $source -y -acodec pcm_s16le $dest
extension: wav
wav: ffmpeg -i $source -y -acodec pcm_s16le $dest
In this example ``beet convert`` will use the *speex* command by
default. To convert the audio to `wav`, run ``beet convert -f wav``.
This will also use the format key (`wav`) as the file extension.
Each entry in the ``formats`` map consists of a key (the name of the
format) as well as the command and the possibly the file extension.
``extension`` is the filename extension to be used for newly transcoded
files. If only the command is given as a string, the file extension
defaults to the formats name. ``command`` is the command-line to use
to transcode audio. The tokens ``$source`` and ``$dest`` in the command
are replaced with the paths to the existing and new file.
The plugin in comes with default commands for the most common audio
formats: `mp3`, `alac`, `flac`, `aac`, `opus`, `ogg`, `wmv`. For
details have a look at the output of ``beet config -d``.
For a one-command-fits-all solution use the ``convert.command`` and
``convert.extension`` options. If these are set the formats are ignored
and the given command is used for all conversions.::
convert:
command: ffmpeg -i $source -y -vn -aq 2 $dest
extension: mp3

View file

@ -17,12 +17,17 @@ Command-Line Interface
beet COMMAND [ARGS...]
Beets also offers command line completion via the `completion`_
command. The rest of this document describes the available
The rest of this document describes the available
commands. If you ever need a quick list of what's available, just
type ``beet help`` or ``beet help COMMAND`` for help with a specific
command.
Beets also offers shell completion. For bash, see the `completion`_
command; for zsh, see the accompanying `completion script`_ for the
``beet`` command.
Commands
--------
@ -399,6 +404,10 @@ Completion of plugin commands only works for those plugins
that were enabled when running ``beet completion``. If you add a plugin
later on you will want to re-generate the script.
If you use zsh, take a look instead at the included `completion script`_.
.. _completion script: https://github.com/sampsyo/beets/blob/master/extra/_beet
.. only:: man

View file

@ -119,6 +119,31 @@ compatibility with Windows-influenced network filesystems like Samba).
Trailing dots and trailing whitespace, which can cause problems on Windows
clients, are also removed.
Note that paths might contain special characters such as typographical
quotes (``“”``). With the configuration above, those will not be
replaced as they don't match the typewriter quote (``"``). To also strip these
special characters, you can either add them to the replacement list or use the
:ref:`asciify-paths` configuration option below.
.. _asciify-paths:
asciify_paths
~~~~~~~~~~~~~
Convert all non-ASCII characters in paths to ASCII equivalents.
For example, if your path template for
singletons is ``singletons/$title`` and the title of a track is "Café",
then the track will be saved as ``singletons/Cafe.mp3``. The changes
take place before applying the :ref:`replace` configuration and are roughly
equivalent to wrapping all your path templates in the ``%asciify{}``
:ref:`template function <template-functions>`.
Default: ``no``.
.. _unidecode module: http://pypi.python.org/pypi/Unidecode
.. _art-filename:
art_filename

View file

@ -69,7 +69,8 @@ These functions are built in to beets:
nothing if ``falsetext`` is left off).
* ``%asciify{text}``: Convert non-ASCII characters to their ASCII equivalents.
For example, "café" becomes "cafe". Uses the mapping provided by the
`unidecode module`_.
`unidecode module`_. See the :ref:`asciify-paths` configuration
option.
* ``%aunique{identifiers,disambiguators}``: Provides a unique string to
disambiguate similar albums in the database. See :ref:`aunique`, below.
* ``%time{date_time,format}``: Return the date and time in any format accepted

View file

@ -156,7 +156,7 @@ Find all items added before the year 2010::
$ beet ls 'added:..2009'
Find all items added on 2008-12-01 but before 2009-10-12::
Find all items added on or after 2008-12-01 but before 2009-10-12::
$ beet ls 'added:2008-12..2009-10-11'

242
extra/_beet Normal file
View file

@ -0,0 +1,242 @@
#compdef beet
# Completion for beets music library manager and MusicBrainz tagger: http://beets.radbox.org/
# useful: argument to _regex_arguments for matching any word
local matchany=/$'[^\0]##\0'/
# Deal with completions for querying and modifying fields..
local fieldargs matchquery matchmodify
local -a fields
# get list of all fields
fields=(`beet fields | grep -G '^ ' | sort -u | colrm 1 2`)
# regexps for matching query and modify terms on the command line
matchquery=/"(${(j/|/)fields[@]})"$':[^\0]##\0'/
matchmodify=/"(${(j/|/)fields[@]})"$'(=[^\0]##|!)\0'/
# Function for getting unique values for field from database (you may need to change the path to the database).
function _beet_field_values()
{
local -a output fieldvals
local library="$(beet config|grep library|cut -f 2 -d ' ')"
output=$(sqlite3 ${~library} "select distinct $1 from items;")
case $1
in
lyrics)
fieldvals=
;;
*)
fieldvals=("${(f)output[@]}")
;;
esac
compadd -P \" -S \" -M 'm:{[:lower:][:upper:]}={[:upper:][:lower:]}' -Q -a fieldvals
}
# store call to _values function for completing query terms
# (first build arguments for completing field values)
local field
for field in "${fields[@]}"
do
fieldargs="$fieldargs '$field:::{_beet_field_values $field}'"
done
local queryelem modifyelem
queryelem="_values -S : 'query field (add an extra : to match by regexp)' '::' $fieldargs"
# store call to _values function for completing modify terms (no need to complete field values)
modifyelem="_values -S = 'modify field (replace = with ! to remove field)' $(echo "'${^fields[@]}:: '")"
# Create completion function for queries
_regex_arguments _beet_query "$matchany" \# \( "$matchquery" ":query:query string:$queryelem" \) \
\( "$matchquery" ":query:query string:$queryelem" \) \#
# store regexps for completing lists of queries and modifications
local -a query modify
query=( \( "$matchquery" ":query:query string:{_beet_query}" \) \( "$matchquery" ":query:query string:{_beet_query}" \) \# )
modify=( \( "$matchmodify" ":modify:modify string:$modifyelem" \) \( "$matchmodify" ":modify:modify string:$modifyelem" \) \# )
# arguments to _regex_arguments for completing files and directories
local -a files dirs
files=("$matchany" ':file:file:_files')
dirs=("$matchany" ':dir:directory:_dirs')
# Individual options used by subcommands, and global options (must be single quoted).
# Its much faster if these are hard-coded rather generated using _beet_subcmd_options
local helpopt formatopt albumopt dontmoveopt writeopt nowriteopt pretendopt pathopt destopt copyopt nocopyopt
local inferopt noinferopt resumeopt noresumeopt nopromptopt logopt individualopt confirmopt retagopt skipopt noskipopt
local flatopt groupopt editopt defaultopt noconfirmopt exactopt removeopt configopt debugopt
helpopt='-h:show this help message and exit'
formatopt='-f:print with custom format:$matchany'
albumopt='-a:match albums instead of tracks'
dontmoveopt='-M:dont move files in library'
writeopt='-w:write new metadata to files tags (default)'
nowriteopt='-W:dont write metadata (opposite of -w)'
pretendopt='-p:show all changes but do nothing'
pathopt='-p:print paths for matched items or albums'
destopt='-d:destination music directory:$dirs'
copyopt='-c:copy tracks into library directory (default)'
nocopyopt='-C:dont copy tracks (opposite of -c)'
inferopt='-a:infer tags for imported files (default)'
noinferopt='-A:dont infer tags for imported files (opposite of -a)'
resumeopt='-p:resume importing if interrupted'
noresumeopt='-P:do not try to resume importing'
nopromptopt='-q:never prompt for input, skip albums instead'
logopt='-l:file to log untaggable albums for later review:$files'
individualopt='-s:import individual tracks instead of full albums'
confirmopt='-t:always confirm all actions'
retagopt='-L:retag items matching a query:${query[@]}'
skipopt='-i:skip already-imported directories'
noskipopt='-I:do not skip already-imported directories'
flatopt='--flat:import an entire tree as a single album'
groupopt='-g:group tracks in a folder into seperate albums'
editopt='-e:edit user configuration with $EDITOR'
defaultopt='-d:include the default configuration'
copynomoveopt='-c:copy instead of moving'
noconfirmopt='-y:skip confirmation'
exactopt='-e:get exact file sizes'
removeopt='-d:also remove files from disk'
configopt='-c:path to configuration file:$files'
debugopt='-v:print debugging information'
libopt='-l:library database file to use:$files'
# This function takes a beet subcommand as its first argument, and then uses _regex_words to set ${reply[@]}
# to an array containing arguments for the _regex_arguments function.
function _beet_subcmd_options()
{
local shortopt optarg optdesc
local -a regex_words
regex_words=()
for i in ${${(f)"$(beet help $1 | awk '/^ +-/{if(x)print x;x=$0;next}/^ *$/{if(x) exit}{if(x) x=x$0}END{print x}')"}[@]}
do
shortopt="${i[(w)1]/,/}"
optarg="${$(echo ${i[(w)2]}|grep -o '[A-Z]\+')[(w)1]}"
optdesc="${${${${${i[(w)2,-1]/[A-Z, ]#--[a-z]##[=A-Z]# #/}//:/-}//\[/(}//\]/)}//\'/}"
case $optarg
in
("")
if [[ "$1" == "import" && "$shortopt" == "-L" ]]; then
regex_words+=("$shortopt:$optdesc:\${query[@]}")
else
regex_words+=("$shortopt:$optdesc")
fi
;;
(LOG)
regex_words+=("$shortopt:$optdesc:\$files")
;;
(CONFIG)
local -a configfile
configfile=("$matchany" ':file:config file:{_files -g *.yaml}')
regex_words+=("$shortopt:$optdesc:\$configfile")
;;
(LIB|LIBRARY)
local -a libfile
libfile=("$matchany" ':file:database file:{_files -g *.db}')
regex_words+=("$shortopt:$optdesc:\$libfile")
;;
(DIR|DIRECTORY)
regex_words+=("$shortopt:$optdesc:\$dirs")
;;
(SOURCE)
if [[ $1 -eq lastgenre ]]; then
local -a lastgenresource
lastgenresource=(/$'(artist|album|track)\0'/ ':source:genre source:(artist album track)')
regex_words+=("$shortopt:$optdesc:\$lastgenresource")
else
regex_words+=("$shortopt:$optdesc:\$matchany")
fi
;;
(*)
regex_words+=("$shortopt:$optdesc:\$matchany")
;;
esac
done
_regex_words options "$1 options" "${regex_words[@]}"
}
# Now build the arguments to _regex_arguments for each subcommand.
local -a options regex_words_subcmds regex_words_help
local subcmd cmddesc
for i in ${${(f)"$(beet help | awk 'f;/Commands:/{f=1}')"[@]}[@]}
do
subcmd="${i[(w)1]}"
cmddesc="${${${${${i[(w)2,-1]##\(*\) #}//:/-}//\[/(}//\]/)}//\'/}"
case $subcmd
in
(config)
_regex_words options "config options" "$helpopt" "$pathopt" "$editopt" "$defaultopt"
options=("${reply[@]}")
;;
(import)
_regex_words options "import options" "$helpopt" "$writeopt" "$nowriteopt" "$copyopt" "$nocopyopt" "$inferopt" \
"$noinferopt" "$resumeopt" "$noresumeopt" "$nopromptopt" "$logopt" "$individualopt" "$confirmopt" "$retagopt" \
"$skipopt" "$noskipopt" "$flatopt" "$groupopt"
options=( "${reply[@]}" \# "${files[@]}" \# )
;;
(list)
_regex_words options "list options" "$helpopt" "$pathopt" "$albumopt" "$formatopt"
options=( "$reply[@]" \# "${query[@]}" )
;;
(modify)
_regex_words options "modify options" "$helpopt" "$dontmoveopt" "$writeopt" "$nowriteopt" "$albumopt" \
"$noconfirmopt" "$formatopt"
options=( "${reply[@]}" \# "${query[@]}" "${modify[@]}" )
;;
(move)
_regex_words options "move options" "$helpopt" "$albumopt" "$destopt" "$copynomoveopt"
options=( "${reply[@]}" \# "${query[@]}")
;;
(remove)
_regex_words options "remove options" "$helpopt" "$albumopt" "$removeopt"
options=( "${reply[@]}" \# "${query[@]}" )
;;
(stats)
_regex_words options "stats options" "$helpopt" "$exactopt"
options=( "${reply[@]}" \# "${query[@]}" )
;;
(update)
_regex_words options "update options" "$helpopt" "$albumopt" "$dontmoveopt" "$pretendopt" "$formatopt"
options=( "${reply[@]}" \# "${query[@]}" )
;;
(write)
_regex_words options "write options" "$helpopt" "$pretendopt"
options=( "${reply[@]}" \# "${query[@]}" )
;;
(fields|migrate|version)
options=()
;;
(help)
# The help subcommand is treated separately
continue
;;
(*) # completions for plugin commands are generated using _beet_subcmd_options
_beet_subcmd_options "$subcmd"
options=( \( "${reply[@]}" \# "${query[@]}" \) )
;;
esac
# Create variable for holding option for this subcommand, and assign to it (needs to have a unique name).
typeset -a opts_for_$subcmd
set -A opts_for_$subcmd ${options[@]} # Assignment MUST be done using set (other methods fail).
regex_words_subcmds+=("$subcmd:$cmddesc:\${(@)opts_for_$subcmd}")
# Add to regex_words args for help subcommand
regex_words_help+=("$subcmd:$cmddesc")
done
local -a opts_for_help
_regex_words subcmds "subcommands" "${regex_words_help[@]}"
opts_for_help=("${reply[@]}")
regex_words_subcmds+=('help:show help:$opts_for_help')
# Argument for global options
local -a globalopts
_regex_words options "global options" "$configopt" "$debugopt" "$libopt" "$helpopt" "$destopt"
globalopts=("${reply[@]}")
# Create main completion function
#local -a subcmds
_regex_words subcmds "subcommands" "${regex_words_subcmds[@]}"
subcmds=("${reply[@]}")
_regex_arguments _beet "$matchany" \( "${globalopts[@]}" \# \) "${subcmds[@]}"
# Set tag-order so that options are completed separately from arguments
zstyle ":completion:${curcontext}:" tag-order '! options'
# Execute the completion function
_beet "$@"
# Local Variables:
# mode:shell-script
# End:

View file

@ -72,9 +72,18 @@ class ConvertCliTest(unittest.TestCase, TestHelper):
self.load_plugins('convert')
self.convert_dest = os.path.join(self.temp_dir, 'convert_dest')
self.config['convert']['dest'] = str(self.convert_dest)
self.config['convert']['command'] = u'cp $source $dest'
self.config['convert']['paths']['default'] = u'converted'
self.config['convert'] = {
'dest': self.convert_dest,
'paths': {'default': 'converted'},
'format': 'mp3',
'formats': {
'mp3': 'cp $source $dest',
'opus': {
'command': 'cp $source $dest',
'extension': 'ops',
}
}
}
def tearDown(self):
self.unload_plugins()
@ -95,6 +104,12 @@ class ConvertCliTest(unittest.TestCase, TestHelper):
self.item.load()
self.assertEqual(os.path.splitext(self.item.path)[1], '.mp3')
def test_format_option(self):
with control_stdin('y'):
self.run_command('convert', '--format', 'opus', self.item.path)
converted = os.path.join(self.convert_dest, 'converted.ops')
self.assertTrue(os.path.isfile(converted))
def test_embed_album_art(self):
self.config['convert']['embed'] = True
image_path = os.path.join(_common.RSRC, 'image-2x3.jpg')

View file

@ -136,6 +136,10 @@ class DestinationTest(_common.TestCase):
super(DestinationTest, self).tearDown()
self.lib._connection().close()
# Reset config if it was changed in test cases
config.clear()
config.read(user=False, defaults=True)
def test_directory_works_with_trailing_slash(self):
self.lib.directory = 'one/'
self.lib.path_formats = [('default', 'two')]
@ -447,6 +451,14 @@ class DestinationTest(_common.TestCase):
dest = self.i.destination(platform='linux2', fragment=True)
self.assertEqual(dest, u'foo.caf\xe9')
def test_asciify_and_replace(self):
config['asciify_paths'] = True
self.lib.replacements = [(re.compile(u'"'), u'q')]
self.lib.directory = 'lib'
self.lib.path_formats = [('default', '$title')]
self.i.title = u'\u201c\u00f6\u2014\u00cf\u201d'
self.assertEqual(self.i.destination(), np('lib/qo--Iq'))
class ItemFormattedMappingTest(_common.LibTestCase):
def test_formatted_item_value(self):

View file

@ -269,21 +269,21 @@ class SoundCheckTest(unittest.TestCase):
class ID3v23Test(unittest.TestCase, TestHelper):
def _make_test(self, ext='mp3'):
def _make_test(self, ext='mp3', id3v23=False):
self.create_temp_dir()
src = os.path.join(_common.RSRC, 'full.{0}'.format(ext))
self.path = os.path.join(self.temp_dir, 'test.{0}'.format(ext))
shutil.copy(src, self.path)
return beets.mediafile.MediaFile(self.path)
return beets.mediafile.MediaFile(self.path, id3v23=id3v23)
def _delete_test(self):
self.remove_temp_dir()
def test_v24_year_tag(self):
mf = self._make_test()
mf = self._make_test(id3v23=False)
try:
mf.year = 2013
mf.save(id3v23=False)
mf.save()
frame = mf.mgfile['TDRC']
self.assertTrue('2013' in str(frame))
self.assertTrue('TYER' not in mf.mgfile)
@ -291,10 +291,10 @@ class ID3v23Test(unittest.TestCase, TestHelper):
self._delete_test()
def test_v23_year_tag(self):
mf = self._make_test()
mf = self._make_test(id3v23=True)
try:
mf.year = 2013
mf.save(id3v23=True)
mf.save()
frame = mf.mgfile['TYER']
self.assertTrue('2013' in str(frame))
self.assertTrue('TDRC' not in mf.mgfile)
@ -302,10 +302,35 @@ class ID3v23Test(unittest.TestCase, TestHelper):
self._delete_test()
def test_v23_on_non_mp3_is_noop(self):
mf = self._make_test('m4a')
mf = self._make_test('m4a', id3v23=True)
try:
mf.year = 2013
mf.save(id3v23=True)
mf.save()
finally:
self._delete_test()
def test_v24_image_encoding(self):
mf = self._make_test(id3v23=False)
try:
mf.images = [beets.mediafile.Image(b'test data')]
mf.save()
frame = mf.mgfile.tags.getall('APIC')[0]
self.assertEqual(frame.encoding, 3)
finally:
self._delete_test()
@unittest.skip
def test_v23_image_encoding(self):
"""For compatibility with OS X/iTunes (and strict adherence to
the standard), ID3v2.3 tags need to use an inferior text
encoding: UTF-8 is not supported.
"""
mf = self._make_test(id3v23=True)
try:
mf.images = [beets.mediafile.Image(b'test data')]
mf.save()
frame = mf.mgfile.tags.getall('APIC')[0]
self.assertEqual(frame.encoding, 1)
finally:
self._delete_test()