Updated beets library to 1.0b11

This commit is contained in:
Remy Varma
2011-11-03 22:03:03 +00:00
parent 0ba1e578ab
commit 5cf995b3dc
27 changed files with 8689 additions and 1059 deletions
+30 -14
View File
@@ -52,6 +52,21 @@ class UserError(Exception):
# Utilities.
def _encoding():
"""Tries to guess the encoding uses by the terminal."""
try:
return locale.getdefaultlocale()[1] or 'utf8'
except ValueError:
# Invalid locale environment variable setting. To avoid
# failing entirely for no good reason, assume UTF-8.
return 'utf8'
def decargs(arglist):
"""Given a list of command-line argument bytestrings, attempts to
decode them to Unicode strings.
"""
return [s.decode(_encoding()) for s in arglist]
def print_(*strings):
"""Like print, but rather than raising an error when a character
is not in the terminal's encoding's character set, just silently
@@ -65,13 +80,7 @@ def print_(*strings):
else:
txt = u''
if isinstance(txt, unicode):
try:
encoding = locale.getdefaultlocale()[1] or 'utf8'
except ValueError:
# Invalid locale environment variable setting. To avoid
# failing entirely for no good reason, assume UTF-8.
encoding = 'utf8'
txt = txt.encode(encoding, 'replace')
txt = txt.encode(_encoding(), 'replace')
print txt
def input_options(options, require=False, prompt=None, fallback_prompt=None,
@@ -247,10 +256,6 @@ def input_yn(prompt, require=False, color=False):
)
return sel == 'y'
def make_query(criteria):
"""Make query string for the list of criteria."""
return ' '.join(criteria).strip() or None
def config_val(config, section, name, default, vtype=None):
"""Queries the configuration file for a value (given by the
section and name). If no value is present, returns default.
@@ -326,9 +331,20 @@ def colorize(color, text):
return escape + text + RESET_COLOR
def colordiff(a, b, highlight='red'):
"""Given two strings, return the same pair of strings except with
their differences highlighted in the specified color.
"""Given two values, return the same pair of strings except with
their differences highlighted in the specified color. Strings are
highlighted intelligently to show differences; other values are
stringified and highlighted in their entirety.
"""
if not isinstance(a, basestring) or not isinstance(b, basestring):
# Non-strings: use ordinary equality.
a = unicode(a)
b = unicode(b)
if a == b:
return a, b
else:
return colorize(highlight, a), colorize(highlight, b)
a_out = []
b_out = []
@@ -351,7 +367,7 @@ def colordiff(a, b, highlight='red'):
else:
assert(False)
return ''.join(a_out), ''.join(b_out)
return u''.join(a_out), u''.join(b_out)
# Subcommand parsing infrastructure.
+632
View File
@@ -0,0 +1,632 @@
# This file is part of beets.
# Copyright 2011, Adrian Sampson.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""This module contains all of the core logic for beets' command-line
interface. To invoke the CLI, just call beets.ui.main(). The actual
CLI commands are implemented in the ui.commands module.
"""
import os
import locale
import optparse
import textwrap
import ConfigParser
import sys
from difflib import SequenceMatcher
import logging
import sqlite3
import errno
from beets import library
from beets import plugins
from beets import util
# Constants.
CONFIG_PATH_VAR = 'BEETSCONFIG'
DEFAULT_CONFIG_FILE = os.path.expanduser('~/.beetsconfig')
DEFAULT_LIBRARY = '~/.beetsmusic.blb'
DEFAULT_DIRECTORY = '~/Music'
DEFAULT_PATH_FORMATS = {
'default': '$albumartist/$album/$track $title',
'comp': 'Compilations/$album/$track $title',
'singleton': 'Non-Album/$artist/$title',
}
DEFAULT_ART_FILENAME = 'cover'
# UI exception. Commands should throw this in order to display
# nonrecoverable errors to the user.
class UserError(Exception):
pass
# Utilities.
def _encoding():
"""Tries to guess the encoding uses by the terminal."""
try:
return locale.getdefaultlocale()[1] or 'utf8'
except ValueError:
# Invalid locale environment variable setting. To avoid
# failing entirely for no good reason, assume UTF-8.
return 'utf8'
def decargs(arglist):
"""Given a list of command-line argument bytestrings, attempts to
decode them to Unicode strings.
"""
return [s.decode(_encoding()) for s in arglist]
def print_(*strings):
"""Like print, but rather than raising an error when a character
is not in the terminal's encoding's character set, just silently
replaces it.
"""
if strings:
if isinstance(strings[0], unicode):
txt = u' '.join(strings)
else:
txt = ' '.join(strings)
else:
txt = u''
if isinstance(txt, unicode):
txt = txt.encode(_encoding(), 'replace')
print txt
def input_options(options, require=False, prompt=None, fallback_prompt=None,
numrange=None, default=None, color=False, max_width=72):
"""Prompts a user for input. The sequence of `options` defines the
choices the user has. A single-letter shortcut is inferred for each
option; the user's choice is returned as that single, lower-case
letter. The options should be provided as lower-case strings unless
a particular shortcut is desired; in that case, only that letter
should be capitalized.
By default, the first option is the default. If `require` is
provided, then there is no default. `default` can be provided to
override this. The prompt and fallback prompt are also inferred but
can be overridden.
If numrange is provided, it is a pair of `(high, low)` (both ints)
indicating that, in addition to `options`, the user may enter an
integer in that inclusive range.
`max_width` specifies the maximum number of columns in the
automatically generated prompt string.
"""
# Assign single letters to each option. Also capitalize the options
# to indicate the letter.
letters = {}
display_letters = []
capitalized = []
first = True
for option in options:
# Is a letter already capitalized?
for letter in option:
if letter.isalpha() and letter.upper() == letter:
found_letter = letter
break
else:
# Infer a letter.
for letter in option:
if not letter.isalpha():
continue # Don't use punctuation.
if letter not in letters:
found_letter = letter
break
else:
raise ValueError('no unambiguous lettering found')
letters[found_letter.lower()] = option
index = option.index(found_letter)
# Mark the option's shortcut letter for display.
if (default is None and not numrange and first) \
or (isinstance(default, basestring) and
found_letter.lower() == default.lower()):
# The first option is the default; mark it.
show_letter = '[%s]' % found_letter.upper()
is_default = True
else:
show_letter = found_letter.upper()
is_default = False
# Possibly colorize the letter shortcut.
if color:
color = 'turquoise' if is_default else 'blue'
show_letter = colorize(color, show_letter)
# Insert the highlighted letter back into the word.
capitalized.append(
option[:index] + show_letter + option[index+1:]
)
display_letters.append(found_letter.upper())
first = False
# The default is just the first option if unspecified.
if default is None:
if require:
default = None
elif numrange:
default = numrange[0]
else:
default = display_letters[0].lower()
# Make a prompt if one is not provided.
if not prompt:
prompt_parts = []
prompt_part_lengths = []
if numrange:
if isinstance(default, int):
default_name = str(default)
if color:
default_name = colorize('turquoise', default_name)
tmpl = '# selection (default %s)'
prompt_parts.append(tmpl % default_name)
prompt_part_lengths.append(len(tmpl % str(default)))
else:
prompt_parts.append('# selection')
prompt_part_lengths.append(prompt_parts[-1])
prompt_parts += capitalized
prompt_part_lengths += [len(s) for s in options]
# Wrap the query text.
prompt = ''
line_length = 0
for i, (part, length) in enumerate(zip(prompt_parts,
prompt_part_lengths)):
# Add punctuation.
if i == len(prompt_parts) - 1:
part += '?'
else:
part += ','
length += 1
# Choose either the current line or the beginning of the next.
if line_length + length + 1 > max_width:
prompt += '\n'
line_length = 0
if line_length != 0:
# Not the beginning of the line; need a space.
part = ' ' + part
length += 1
prompt += part
line_length += length
# Make a fallback prompt too. This is displayed if the user enters
# something that is not recognized.
if not fallback_prompt:
fallback_prompt = 'Enter one of '
if numrange:
fallback_prompt += '%i-%i, ' % numrange
fallback_prompt += ', '.join(display_letters) + ':'
# (raw_input(prompt) was causing problems with colors.)
print prompt,
resp = raw_input()
while True:
resp = resp.strip().lower()
# Try default option.
if default is not None and not resp:
resp = default
# Try an integer input if available.
if numrange:
try:
resp = int(resp)
except ValueError:
pass
else:
low, high = numrange
if low <= resp <= high:
return resp
else:
resp = None
# Try a normal letter input.
if resp:
resp = resp[0]
if resp in letters:
return resp
# Prompt for new input.
print fallback_prompt,
resp = raw_input()
def input_yn(prompt, require=False, color=False):
"""Prompts the user for a "yes" or "no" response. The default is
"yes" unless `require` is `True`, in which case there is no default.
"""
sel = input_options(
('y', 'n'), require, prompt, 'Enter Y or N:', color=color
)
return sel == 'y'
def config_val(config, section, name, default, vtype=None):
"""Queries the configuration file for a value (given by the
section and name). If no value is present, returns default.
vtype optionally specifies the return type (although only bool
is supported for now).
"""
if not config.has_section(section):
config.add_section(section)
try:
if vtype is bool:
return config.getboolean(section, name)
else:
return config.get(section, name)
except ConfigParser.NoOptionError:
return default
def human_bytes(size):
"""Formats size, a number of bytes, in a human-readable way."""
suffices = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB', 'HB']
for suffix in suffices:
if size < 1024:
return "%3.1f %s" % (size, suffix)
size /= 1024.0
return "big"
def human_seconds(interval):
"""Formats interval, a number of seconds, as a human-readable time
interval.
"""
units = [
(1, 'second'),
(60, 'minute'),
(60, 'hour'),
(24, 'day'),
(7, 'week'),
(52, 'year'),
(10, 'decade'),
]
for i in range(len(units)-1):
increment, suffix = units[i]
next_increment, _ = units[i+1]
interval /= float(increment)
if interval < next_increment:
break
else:
# Last unit.
increment, suffix = units[-1]
interval /= float(increment)
return "%3.1f %ss" % (interval, suffix)
# ANSI terminal colorization code heavily inspired by pygments:
# http://dev.pocoo.org/hg/pygments-main/file/b2deea5b5030/pygments/console.py
# (pygments is by Tim Hatch, Armin Ronacher, et al.)
COLOR_ESCAPE = "\x1b["
DARK_COLORS = ["black", "darkred", "darkgreen", "brown", "darkblue",
"purple", "teal", "lightgray"]
LIGHT_COLORS = ["darkgray", "red", "green", "yellow", "blue",
"fuchsia", "turquoise", "white"]
RESET_COLOR = COLOR_ESCAPE + "39;49;00m"
def colorize(color, text):
"""Returns a string that prints the given text in the given color
in a terminal that is ANSI color-aware. The color must be something
in DARK_COLORS or LIGHT_COLORS.
"""
if color in DARK_COLORS:
escape = COLOR_ESCAPE + "%im" % (DARK_COLORS.index(color) + 30)
elif color in LIGHT_COLORS:
escape = COLOR_ESCAPE + "%i;01m" % (LIGHT_COLORS.index(color) + 30)
else:
raise ValueError('no such color %s', color)
return escape + text + RESET_COLOR
def colordiff(a, b, highlight='red'):
"""Given two values, return the same pair of strings except with
their differences highlighted in the specified color. Strings are
highlighted intelligently to show differences; other values are
stringified and highlighted in their entirety.
"""
if not isinstance(a, basestring) or not isinstance(b, basestring):
# Non-strings: use ordinary equality.
a = unicode(a)
b = unicode(b)
if a == b:
return a, b
else:
return colorize(highlight, a), colorize(highlight, b)
a_out = []
b_out = []
matcher = SequenceMatcher(lambda x: False, a, b)
for op, a_start, a_end, b_start, b_end in matcher.get_opcodes():
if op == 'equal':
# In both strings.
a_out.append(a[a_start:a_end])
b_out.append(b[b_start:b_end])
elif op == 'insert':
# Right only.
b_out.append(colorize(highlight, b[b_start:b_end]))
elif op == 'delete':
# Left only.
a_out.append(colorize(highlight, a[a_start:a_end]))
elif op == 'replace':
# Right and left differ.
a_out.append(colorize(highlight, a[a_start:a_end]))
b_out.append(colorize(highlight, b[b_start:b_end]))
else:
assert(False)
return u''.join(a_out), u''.join(b_out)
# Subcommand parsing infrastructure.
# This is a fairly generic subcommand parser for optparse. It is
# maintained externally here:
# http://gist.github.com/462717
# There you will also find a better description of the code and a more
# succinct example program.
class Subcommand(object):
"""A subcommand of a root command-line application that may be
invoked by a SubcommandOptionParser.
"""
def __init__(self, name, parser=None, help='', aliases=()):
"""Creates a new subcommand. name is the primary way to invoke
the subcommand; aliases are alternate names. parser is an
OptionParser responsible for parsing the subcommand's options.
help is a short description of the command. If no parser is
given, it defaults to a new, empty OptionParser.
"""
self.name = name
self.parser = parser or optparse.OptionParser()
self.aliases = aliases
self.help = help
class SubcommandsOptionParser(optparse.OptionParser):
"""A variant of OptionParser that parses subcommands and their
arguments.
"""
# A singleton command used to give help on other subcommands.
_HelpSubcommand = Subcommand('help', optparse.OptionParser(),
help='give detailed help on a specific sub-command',
aliases=('?',))
def __init__(self, *args, **kwargs):
"""Create a new subcommand-aware option parser. All of the
options to OptionParser.__init__ are supported in addition
to subcommands, a sequence of Subcommand objects.
"""
# The subcommand array, with the help command included.
self.subcommands = list(kwargs.pop('subcommands', []))
self.subcommands.append(self._HelpSubcommand)
# A more helpful default usage.
if 'usage' not in kwargs:
kwargs['usage'] = """
%prog COMMAND [ARGS...]
%prog help COMMAND"""
# Super constructor.
optparse.OptionParser.__init__(self, *args, **kwargs)
# Adjust the help-visible name of each subcommand.
for subcommand in self.subcommands:
subcommand.parser.prog = '%s %s' % \
(self.get_prog_name(), subcommand.name)
# Our root parser needs to stop on the first unrecognized argument.
self.disable_interspersed_args()
def add_subcommand(self, cmd):
"""Adds a Subcommand object to the parser's list of commands.
"""
self.subcommands.append(cmd)
# Add the list of subcommands to the help message.
def format_help(self, formatter=None):
# Get the original help message, to which we will append.
out = optparse.OptionParser.format_help(self, formatter)
if formatter is None:
formatter = self.formatter
# Subcommands header.
result = ["\n"]
result.append(formatter.format_heading('Commands'))
formatter.indent()
# Generate the display names (including aliases).
# Also determine the help position.
disp_names = []
help_position = 0
for subcommand in self.subcommands:
name = subcommand.name
if subcommand.aliases:
name += ' (%s)' % ', '.join(subcommand.aliases)
disp_names.append(name)
# Set the help position based on the max width.
proposed_help_position = len(name) + formatter.current_indent + 2
if proposed_help_position <= formatter.max_help_position:
help_position = max(help_position, proposed_help_position)
# Add each subcommand to the output.
for subcommand, name in zip(self.subcommands, disp_names):
# Lifted directly from optparse.py.
name_width = help_position - formatter.current_indent - 2
if len(name) > name_width:
name = "%*s%s\n" % (formatter.current_indent, "", name)
indent_first = help_position
else:
name = "%*s%-*s " % (formatter.current_indent, "",
name_width, name)
indent_first = 0
result.append(name)
help_width = formatter.width - help_position
help_lines = textwrap.wrap(subcommand.help, help_width)
result.append("%*s%s\n" % (indent_first, "", help_lines[0]))
result.extend(["%*s%s\n" % (help_position, "", line)
for line in help_lines[1:]])
formatter.dedent()
# Concatenate the original help message with the subcommand
# list.
return out + "".join(result)
def _subcommand_for_name(self, name):
"""Return the subcommand in self.subcommands matching the
given name. The name may either be the name of a subcommand or
an alias. If no subcommand matches, returns None.
"""
for subcommand in self.subcommands:
if name == subcommand.name or \
name in subcommand.aliases:
return subcommand
return None
def parse_args(self, a=None, v=None):
"""Like OptionParser.parse_args, but returns these four items:
- options: the options passed to the root parser
- subcommand: the Subcommand object that was invoked
- suboptions: the options passed to the subcommand parser
- subargs: the positional arguments passed to the subcommand
"""
options, args = optparse.OptionParser.parse_args(self, a, v)
if not args:
# No command given.
self.print_help()
self.exit()
else:
cmdname = args.pop(0)
subcommand = self._subcommand_for_name(cmdname)
if not subcommand:
self.error('unknown command ' + cmdname)
suboptions, subargs = subcommand.parser.parse_args(args)
if subcommand is self._HelpSubcommand:
if subargs:
# particular
cmdname = subargs[0]
helpcommand = self._subcommand_for_name(cmdname)
helpcommand.parser.print_help()
self.exit()
else:
# general
self.print_help()
self.exit()
return options, subcommand, suboptions, subargs
# The root parser and its main function.
def main(args=None, configfh=None):
"""Run the main command-line interface for beets."""
# Get the default subcommands.
from beets.ui.commands import default_commands
# Read defaults from config file.
config = ConfigParser.SafeConfigParser()
if configfh:
configpath = None
elif CONFIG_PATH_VAR in os.environ:
configpath = os.path.expanduser(os.environ[CONFIG_PATH_VAR])
else:
configpath = DEFAULT_CONFIG_FILE
if configpath:
configpath = util.syspath(configpath)
if os.path.exists(util.syspath(configpath)):
configfh = open(configpath)
else:
configfh = None
if configfh:
config.readfp(configfh)
# Add plugin paths.
plugpaths = config_val(config, 'beets', 'pluginpath', '')
for plugpath in plugpaths.split(':'):
sys.path.append(os.path.expanduser(plugpath))
# Load requested plugins.
plugnames = config_val(config, 'beets', 'plugins', '')
plugins.load_plugins(plugnames.split())
plugins.load_listeners()
plugins.send("pluginload")
plugins.configure(config)
# Construct the root parser.
commands = list(default_commands)
commands += plugins.commands()
parser = SubcommandsOptionParser(subcommands=commands)
parser.add_option('-l', '--library', dest='libpath',
help='library database file to use')
parser.add_option('-d', '--directory', dest='directory',
help="destination music directory")
parser.add_option('-p', '--pathformat', dest='path_format',
help="destination path format string")
parser.add_option('-v', '--verbose', dest='verbose', action='store_true',
help='print debugging information')
# Parse the command-line!
options, subcommand, suboptions, subargs = parser.parse_args(args)
# Open library file.
libpath = options.libpath or \
config_val(config, 'beets', 'library', DEFAULT_LIBRARY)
directory = options.directory or \
config_val(config, 'beets', 'directory', DEFAULT_DIRECTORY)
legacy_path_format = config_val(config, 'beets', 'path_format', None)
if options.path_format:
# If given, -p overrides all path format settings
path_formats = {'default': options.path_format}
else:
if legacy_path_format:
# Old path formats override the default values.
path_formats = {'default': legacy_path_format}
else:
# If no legacy path format, use the defaults instead.
path_formats = DEFAULT_PATH_FORMATS
if config.has_section('paths'):
path_formats.update(config.items('paths'))
art_filename = \
config_val(config, 'beets', 'art_filename', DEFAULT_ART_FILENAME)
db_path = os.path.expanduser(libpath)
try:
lib = library.Library(db_path,
directory,
path_formats,
art_filename)
except sqlite3.OperationalError:
raise UserError("database file %s could not be opened" % db_path)
# Configure the logger.
log = logging.getLogger('beets')
if options.verbose:
log.setLevel(logging.DEBUG)
else:
log.setLevel(logging.INFO)
# Invoke the subcommand.
try:
subcommand.func(lib, config, suboptions, subargs)
except UserError, exc:
message = exc.args[0] if exc.args else None
subcommand.parser.error(message)
except IOError, exc:
if exc.errno == errno.EPIPE:
# "Broken pipe". End silently.
pass
else:
raise
+358 -44
View File
@@ -20,14 +20,17 @@ import logging
import sys
import os
import time
import itertools
import re
from lib.beets import ui
from lib.beets.ui import print_
from lib.beets.ui import print_, decargs
from lib.beets import autotag
import lib.beets.autotag.art as beets.autotag.art
import lib.beets.autotag.art
from lib.beets import plugins
from lib.beets import importer
from lib.beets.util import syspath, normpath
from lib.beets.util import syspath, normpath, ancestry
from lib.beets import library
# Global logger.
log = logging.getLogger('beets')
@@ -36,6 +39,49 @@ log = logging.getLogger('beets')
# objects that can be fed to a SubcommandsOptionParser.
default_commands = []
# Utility.
def _do_query(lib, query, album, also_items=True):
"""For commands that operate on matched items, performs a query
and returns a list of matching items and a list of matching
albums. (The latter is only nonempty when album is True.) Raises
a UserError if no items match. also_items controls whether, when
fetching albums, the associated items should be fetched also.
"""
if album:
albums = list(lib.albums(query))
items = []
if also_items:
for al in albums:
items += al.items()
else:
albums = []
items = list(lib.items(query))
if album and not albums:
raise ui.UserError('No matching albums found.')
elif not album and not items:
raise ui.UserError('No matching items found.')
return items, albums
FLOAT_EPSILON = 0.01
def _showdiff(field, oldval, newval, color):
"""Prints out a human-readable field difference line."""
# Considering floats incomparable for perfect equality, introduce
# an epsilon tolerance.
if isinstance(oldval, float) and isinstance(newval, float) and \
abs(oldval - newval) < FLOAT_EPSILON:
return
if newval != oldval:
if color:
oldval, newval = ui.colordiff(oldval, newval)
else:
oldval, newval = unicode(oldval), unicode(newval)
print_(u' %s: %s -> %s' % (field, oldval, newval))
# import: Autotagger and importer.
@@ -48,6 +94,7 @@ DEFAULT_IMPORT_ART = True
DEFAULT_IMPORT_QUIET = False
DEFAULT_IMPORT_QUIET_FALLBACK = 'skip'
DEFAULT_IMPORT_RESUME = None # "ask"
DEFAULT_IMPORT_INCREMENTAL = False
DEFAULT_THREADED = True
DEFAULT_COLOR = True
@@ -83,10 +130,10 @@ def show_change(cur_artist, cur_album, items, info, dist, color=True):
print_(' (unknown album)')
# Identify the album in question.
if cur_artist != info['artist'] or \
(cur_album != info['album'] and info['album'] != VARIOUS_ARTISTS):
artist_l, artist_r = cur_artist or '', info['artist']
album_l, album_r = cur_album or '', info['album']
if cur_artist != info.artist or \
(cur_album != info.album and info.album != VARIOUS_ARTISTS):
artist_l, artist_r = cur_artist or '', info.artist
album_l, album_r = cur_album or '', info.album
if artist_r == VARIOUS_ARTISTS:
# Hide artists for VA releases.
artist_l, artist_r = u'', u''
@@ -100,17 +147,17 @@ def show_change(cur_artist, cur_album, items, info, dist, color=True):
print_("To:")
show_album(artist_r, album_r)
else:
print_("Tagging: %s - %s" % (info['artist'], info['album']))
print_("Tagging: %s - %s" % (info.artist, info.album))
# Distance/similarity.
print_('(Similarity: %s)' % dist_string(dist, color))
# Tracks.
for i, (item, track_data) in enumerate(zip(items, info['tracks'])):
for i, (item, track_info) in enumerate(zip(items, info.tracks)):
cur_track = str(item.track)
new_track = str(i+1)
cur_title = item.title
new_title = track_data['title']
new_title = track_info.title
# Possibly colorize changes.
if color:
@@ -118,6 +165,10 @@ def show_change(cur_artist, cur_album, items, info, dist, color=True):
if cur_track != new_track:
cur_track = ui.colorize('red', cur_track)
new_track = ui.colorize('red', new_track)
# Show filename (non-colorized) when title is not set.
if not item.title.strip():
cur_title = os.path.basename(item.path)
if cur_title != new_title and cur_track != new_track:
print_(" * %s (%s) -> %s (%s)" % (
@@ -132,8 +183,8 @@ def show_item_change(item, info, dist, color):
"""Print out the change that would occur by tagging `item` with the
metadata from `info`.
"""
cur_artist, new_artist = item.artist, info['artist']
cur_title, new_title = item.title, info['title']
cur_artist, new_artist = item.artist, info.artist
cur_title, new_title = item.title, info.title
if cur_artist != new_artist or cur_title != new_title:
if color:
@@ -177,7 +228,7 @@ def choose_candidate(candidates, singleton, rec, color, timid,
Returns the result of the choice, which may SKIP, ASIS, TRACKS, or
MANUAL or a candidate. For albums, a candidate is a `(info, items)`
pair; for items, it is just an `info` dictionary.
pair; for items, it is just a TrackInfo object.
"""
# Sanity check.
if singleton:
@@ -237,8 +288,24 @@ def choose_candidate(candidates, singleton, rec, color, timid,
(cur_artist, cur_album))
print_('Candidates:')
for i, (dist, items, info) in enumerate(candidates):
print_('%i. %s - %s (%s)' % (i+1, info['artist'],
info['album'], dist_string(dist, color)))
line = '%i. %s - %s' % (i+1, info['artist'],
info['album'])
# Label and year disambiguation, if available.
label, year = None, None
if 'label' in info:
label = info['label']
if 'year' in info and info['year']:
year = unicode(info['year'])
if label and year:
line += u' [%s, %s]' % (label, year)
elif label:
line += u' [%s]' % label
elif year:
line += u' [%s]' % year
line += ' (%s)' % dist_string(dist, color)
print_(line)
# Ask the user for a choice.
if singleton:
@@ -321,10 +388,20 @@ def manual_search(singleton):
return artist.strip(), name.strip()
def manual_id(singleton):
"""Input a MusicBrainz ID, either for an album or a track.
"""Input a MusicBrainz ID, either for an album ("release") or a
track ("recording"). If no valid ID is entered, returns None.
"""
prompt = 'Enter MusicBrainz %s ID: ' % ('track' if singleton else 'album')
return raw_input(prompt).decode(sys.stdin.encoding).strip()
prompt = 'Enter MusicBrainz %s ID: ' % \
('recording' if singleton else 'release')
entry = raw_input(prompt).decode(sys.stdin.encoding).strip()
# Find the first thing that looks like a UUID/MBID.
match = re.search('[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}', entry)
if match:
return match.group()
else:
log.error('Invalid MBID.')
return None
def choose_match(task, config):
"""Given an initial autotagging of items, go through an interactive
@@ -370,12 +447,13 @@ def choose_match(task, config):
elif choice is importer.action.MANUAL_ID:
# Try a manually-entered ID.
search_id = manual_id(False)
try:
_, _, candidates, rec = \
autotag.tag_album(task.items, config.timid,
search_id=search_id)
except autotag.AutotagError:
candidates, rec = None, None
if search_id:
try:
_, _, candidates, rec = \
autotag.tag_album(task.items, config.timid,
search_id=search_id)
except autotag.AutotagError:
candidates, rec = None, None
else:
# We have a candidate! Finish tagging. Here, choice is
# an (info, items) pair as desired.
@@ -384,7 +462,7 @@ def choose_match(task, config):
def choose_item(task, config):
"""Ask the user for a choice about tagging a single item. Returns
either an action constant or a track info dictionary.
either an action constant or a TrackInfo object.
"""
print_()
print_(task.item.path)
@@ -416,8 +494,9 @@ def choose_item(task, config):
elif choice == importer.action.MANUAL_ID:
# Ask for a track ID.
search_id = manual_id(True)
candidates, rec = autotag.tag_item(task.item, config.timid,
search_id=search_id)
if search_id:
candidates, rec = autotag.tag_item(task.item, config.timid,
search_id=search_id)
else:
# Chose a candidate.
assert not isinstance(choice, importer.action)
@@ -427,7 +506,7 @@ def choose_item(task, config):
def import_files(lib, paths, copy, write, autot, logpath, art, threaded,
color, delete, quiet, resume, quiet_fallback, singletons,
timid):
timid, query, incremental):
"""Import the files in the given list of paths, tagging each leaf
directory as an album. If copy, then the files are copied into
the library folder. If write, then new metadata is written to the
@@ -487,6 +566,8 @@ def import_files(lib, paths, copy, write, autot, logpath, art, threaded,
singletons = singletons,
timid = timid,
choose_item_func = choose_item,
query = query,
incremental = incremental,
)
# If we were logging, close the file.
@@ -528,6 +609,10 @@ import_cmd.parser.add_option('-s', '--singletons', action='store_true',
help='import individual tracks instead of full albums')
import_cmd.parser.add_option('-t', '--timid', dest='timid',
action='store_true', help='always confirm all actions')
import_cmd.parser.add_option('-L', '--library', dest='library',
action='store_true', help='retag items matching a query')
import_cmd.parser.add_option('-i', '--incremental', dest='incremental',
action='store_true', help='skip already-imported directories')
def import_func(lib, config, opts, args):
copy = opts.copy if opts.copy is not None else \
ui.config_val(config, 'beets', 'import_copy',
@@ -553,6 +638,9 @@ def import_func(lib, config, opts, args):
DEFAULT_IMPORT_TIMID, bool)
logpath = opts.logpath if opts.logpath is not None else \
ui.config_val(config, 'beets', 'import_log', None)
incremental = opts.incremental if opts.incremental is not None else \
ui.config_val(config, 'beets', 'import_incremental',
DEFAULT_IMPORT_INCREMENTAL, bool)
# Resume has three options: yes, no, and "ask" (None).
resume = opts.resume if opts.resume is not None else \
@@ -569,9 +657,17 @@ def import_func(lib, config, opts, args):
quiet_fallback = importer.action.ASIS
else:
quiet_fallback = importer.action.SKIP
import_files(lib, args, copy, write, autot, logpath, art, threaded,
if opts.library:
query = args
paths = []
else:
query = None
paths = args
import_files(lib, paths, copy, write, autot, logpath, art, threaded,
color, delete, quiet, resume, quiet_fallback, singletons,
timid)
timid, query, incremental)
import_cmd.func = import_func
default_commands.append(import_cmd)
@@ -602,11 +698,104 @@ list_cmd.parser.add_option('-a', '--album', action='store_true',
list_cmd.parser.add_option('-p', '--path', action='store_true',
help='print paths for matched items or albums')
def list_func(lib, config, opts, args):
list_items(lib, ui.make_query(args), opts.album, opts.path)
list_items(lib, decargs(args), opts.album, opts.path)
list_cmd.func = list_func
default_commands.append(list_cmd)
# update: Update library contents according to on-disk tags.
def update_items(lib, query, album, move, color, pretend):
"""For all the items matched by the query, update the library to
reflect the item's embedded tags.
"""
items, _ = _do_query(lib, query, album)
# Walk through the items and pick up their changes.
affected_albums = set()
for item in items:
# Item deleted?
if not os.path.exists(syspath(item.path)):
print_(u'X %s - %s' % (item.artist, item.title))
if not pretend:
lib.remove(item, True)
affected_albums.add(item.album_id)
continue
# Read new data.
old_data = dict(item.record)
item.read()
# Special-case album artist when it matches track artist. (Hacky
# but necessary for preserving album-level metadata for non-
# autotagged imports.)
if not item.albumartist and \
old_data['albumartist'] == old_data['artist'] == item.artist:
item.albumartist = old_data['albumartist']
item.dirty['albumartist'] = False
# Get and save metadata changes.
changes = {}
for key in library.ITEM_KEYS_META:
if item.dirty[key]:
changes[key] = old_data[key], getattr(item, key)
if changes:
# Something changed.
print_(u'* %s - %s' % (item.artist, item.title))
for key, (oldval, newval) in changes.iteritems():
_showdiff(key, oldval, newval, color)
# If we're just pretending, then don't move or save.
if pretend:
continue
# Move the item if it's in the library.
if move and lib.directory in ancestry(item.path):
lib.move(item)
lib.store(item)
affected_albums.add(item.album_id)
# Skip album changes while pretending.
if pretend:
return
# Modify affected albums to reflect changes in their items.
for album_id in affected_albums:
if album_id is None: # Singletons.
continue
album = lib.get_album(album_id)
if not album: # Empty albums have already been removed.
log.debug('emptied album %i' % album_id)
continue
al_items = list(album.items())
# Update album structure to reflect an item in it.
for key in library.ALBUM_KEYS_ITEM:
setattr(album, key, getattr(al_items[0], key))
# Move album art (and any inconsistent items).
if move and lib.directory in ancestry(al_items[0].path):
log.debug('moving album %i' % album_id)
album.move()
lib.save()
update_cmd = ui.Subcommand('update',
help='update the library', aliases=('upd','up',))
update_cmd.parser.add_option('-a', '--album', action='store_true',
help='show matching albums instead of tracks')
update_cmd.parser.add_option('-M', '--nomove', action='store_false',
default=True, dest='move', help="don't move files in library")
update_cmd.parser.add_option('-p', '--pretend', action='store_true',
help="show all changes but do nothing")
def update_func(lib, config, opts, args):
color = ui.config_val(config, 'beets', 'color', DEFAULT_COLOR, bool)
update_items(lib, decargs(args), opts.album, opts.move, color, opts.pretend)
update_cmd.func = update_func
default_commands.append(update_cmd)
# remove: Remove items from library, delete files.
def remove_items(lib, query, album, delete=False):
@@ -614,17 +803,7 @@ def remove_items(lib, query, album, delete=False):
remove whole albums. If delete, also remove files from disk.
"""
# Get the matching items.
if album:
albums = list(lib.albums(query))
items = []
for al in albums:
items += al.items()
else:
items = list(lib.items(query))
if not items:
print_('No matching items found.')
return
items, albums = _do_query(lib, query, album)
# Show all the items.
for item in items:
@@ -657,7 +836,7 @@ remove_cmd.parser.add_option("-d", "--delete", action="store_true",
remove_cmd.parser.add_option('-a', '--album', action='store_true',
help='match albums instead of tracks')
def remove_func(lib, config, opts, args):
remove_items(lib, ui.make_query(args), opts.album, opts.delete)
remove_items(lib, decargs(args), opts.album, opts.delete)
remove_cmd.func = remove_func
default_commands.append(remove_cmd)
@@ -698,7 +877,7 @@ Albums: %i""" % (
stats_cmd = ui.Subcommand('stats',
help='show statistics about the library or a query')
def stats_func(lib, config, opts, args):
show_stats(lib, ui.make_query(args))
show_stats(lib, decargs(args))
stats_cmd.func = stats_func
default_commands.append(stats_cmd)
@@ -720,3 +899,138 @@ version_cmd = ui.Subcommand('version',
help='output version information')
version_cmd.func = show_version
default_commands.append(version_cmd)
# modify: Declaratively change metadata.
def modify_items(lib, mods, query, write, move, album, color, confirm):
"""Modifies matching items according to key=value assignments."""
# Parse key=value specifications into a dictionary.
allowed_keys = library.ALBUM_KEYS if album else library.ITEM_KEYS_WRITABLE
fsets = {}
for mod in mods:
key, value = mod.split('=', 1)
if key not in allowed_keys:
raise ui.UserError('"%s" is not a valid field' % key)
fsets[key] = value
# Get the items to modify.
items, albums = _do_query(lib, query, album, False)
objs = albums if album else items
# Preview change.
print_('Modifying %i %ss.' % (len(objs), 'album' if album else 'item'))
for obj in objs:
# Identify the changed object.
if album:
print_(u'* %s - %s' % (obj.albumartist, obj.album))
else:
print_(u'* %s - %s' % (obj.artist, obj.title))
# Show each change.
for field, value in fsets.iteritems():
curval = getattr(obj, field)
_showdiff(field, curval, value, color)
# Confirm.
if confirm:
extra = ' and write tags' if write else ''
if not ui.input_yn('Really modify%s (Y/n)?' % extra):
return
# Apply changes to database.
for obj in objs:
for field, value in fsets.iteritems():
setattr(obj, field, value)
if move:
cur_path = obj.item_dir() if album else obj.path
if lib.directory in ancestry(cur_path): # In library?
log.debug('moving object %s' % cur_path)
if album:
obj.move()
else:
lib.move(obj)
# When modifying items, we have to store them to the database.
if not album:
lib.store(obj)
lib.save()
# Apply tags if requested.
if write:
if album:
items = itertools.chain(*(a.items() for a in albums))
for item in items:
item.write()
modify_cmd = ui.Subcommand('modify',
help='change metadata fields', aliases=('mod',))
modify_cmd.parser.add_option('-M', '--nomove', action='store_false',
default=True, dest='move', help="don't move files in library")
modify_cmd.parser.add_option('-w', '--write', action='store_true',
default=None, help="write new metadata to files' tags (default)")
modify_cmd.parser.add_option('-W', '--nowrite', action='store_false',
dest='write', help="don't write metadata (opposite of -w)")
modify_cmd.parser.add_option('-a', '--album', action='store_true',
help='modify whole albums instead of tracks')
modify_cmd.parser.add_option('-y', '--yes', action='store_true',
help='skip confirmation')
def modify_func(lib, config, opts, args):
args = decargs(args)
mods = [a for a in args if '=' in a]
query = [a for a in args if '=' not in a]
if not mods:
raise ui.UserError('no modifications specified')
write = opts.write if opts.write is not None else \
ui.config_val(config, 'beets', 'import_write',
DEFAULT_IMPORT_WRITE, bool)
color = ui.config_val(config, 'beets', 'color', DEFAULT_COLOR, bool)
modify_items(lib, mods, query, write, opts.move, opts.album, color,
not opts.yes)
modify_cmd.func = modify_func
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):
"""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.
"""
items, albums = _do_query(lib, query, album, False)
objs = albums if album else items
action = 'Copying' if copy else 'Moving'
entity = 'album' if album else 'item'
logging.info('%s %i %ss.' % (action, len(objs), entity))
for obj in objs:
old_path = obj.item_dir() if album else obj.path
logging.debug('moving: %s' % old_path)
if album:
obj.move(copy, basedir=dest)
else:
lib.move(obj, copy, basedir=dest)
lib.store(obj)
lib.save()
move_cmd = ui.Subcommand('move',
help='move or copy items', aliases=('mv',))
move_cmd.parser.add_option('-d', '--dest', metavar='DIR', dest='dest',
help='destination directory')
move_cmd.parser.add_option('-c', '--copy', default=False, action='store_true',
help='copy instead of moving')
move_cmd.parser.add_option('-a', '--album', default=False, action='store_true',
help='match whole albums instead of tracks')
def move_func(lib, config, opts, args):
dest = opts.dest
if dest is not None:
dest = normpath(dest)
if not os.path.isdir(dest):
raise ui.UserError('no such directory: %s' % dest)
move_items(lib, dest, decargs(args), opts.copy, opts.album)
move_cmd.func = move_func
default_commands.append(move_cmd)
+1036
View File
File diff suppressed because it is too large Load Diff