mirror of
https://github.com/beetbox/beets.git
synced 2025-12-28 11:32:30 +01:00
Merge branch 'master' of https://github.com/sampsyo/beets into spotify-plugin
This commit is contained in:
commit
6bab9a2cae
18 changed files with 582 additions and 194 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ replace:
|
|||
'\s+$': ''
|
||||
'^\s+': ''
|
||||
path_sep_replace: _
|
||||
asciify_paths: false
|
||||
art_filename: cover
|
||||
max_filename_length: 0
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 format’s 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
242
extra/_beet
Normal 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:
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue