InRough update of the beets lib to 1.0b15

This commit is contained in:
rembo10
2012-07-28 23:45:08 +05:30
parent c1edd5085a
commit d245428ca2
17 changed files with 2786 additions and 1263 deletions
+93 -52
View File
@@ -1,5 +1,5 @@
# This file is part of beets.
# Copyright 2011, Adrian Sampson.
# Copyright 2012, Adrian Sampson.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
@@ -8,7 +8,7 @@
# 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.
@@ -16,6 +16,8 @@
interface. To invoke the CLI, just call beets.ui.main(). The actual
CLI commands are implemented in the ui.commands module.
"""
from __future__ import print_function
import os
import locale
import optparse
@@ -27,14 +29,22 @@ import logging
import sqlite3
import errno
import re
import codecs
from lib.beets import library
from lib.beets import plugins
from lib.beets import util
from lib.beets.util.functemplate import Template
# On Windows platforms, use colorama to support "ANSI" terminal colors.
if sys.platform == 'win32':
import colorama
colorama.init()
try:
import colorama
except ImportError:
pass
else:
colorama.init()
# Constants.
CONFIG_PATH_VAR = 'BEETSCONFIG'
@@ -49,9 +59,12 @@ PF_KEY_QUERIES = {
'singleton': 'singleton:true',
}
DEFAULT_PATH_FORMATS = [
(library.PF_KEY_DEFAULT, '$albumartist/$album/$track $title'),
(PF_KEY_QUERIES['singleton'], 'Non-Album/$artist/$title'),
(PF_KEY_QUERIES['comp'], 'Compilations/$album/$track $title'),
(library.PF_KEY_DEFAULT,
Template('$albumartist/$album%aunique{}/$track $title')),
(PF_KEY_QUERIES['singleton'],
Template('Non-Album/$artist/$title')),
(PF_KEY_QUERIES['comp'],
Template('Compilations/$album%aunique{}/$track $title')),
]
DEFAULT_ART_FILENAME = 'cover'
DEFAULT_TIMEOUT = 5.0
@@ -94,7 +107,28 @@ def print_(*strings):
txt = u''
if isinstance(txt, unicode):
txt = txt.encode(_encoding(), 'replace')
print txt
print(txt)
def input_(prompt=None):
"""Like `raw_input`, but decodes the result to a Unicode string.
Raises a UserError if stdin is not available. The prompt is sent to
stdout rather than stderr. A printed between the prompt and the
input cursor.
"""
# raw_input incorrectly sends prompts to stderr, not stdout, so we
# use print() explicitly to display prompts.
# http://bugs.python.org/issue1927
if prompt:
if isinstance(prompt, unicode):
prompt = prompt.encode(_encoding(), 'replace')
print(prompt, end=' ')
try:
resp = raw_input()
except EOFError:
raise UserError('stdin stream ended while input required')
return resp.decode(sys.stdin.encoding, 'ignore')
def input_options(options, require=False, prompt=None, fallback_prompt=None,
numrange=None, default=None, color=False, max_width=72):
@@ -145,7 +179,7 @@ def input_options(options, require=False, prompt=None, fallback_prompt=None,
# Mark the option's shortcut letter for display.
if (default is None and not numrange and first) \
or (isinstance(default, basestring) and
or (isinstance(default, basestring) and
found_letter.lower() == default.lower()):
# The first option is the default; mark it.
show_letter = '[%s]' % found_letter.upper()
@@ -175,7 +209,7 @@ def input_options(options, require=False, prompt=None, fallback_prompt=None,
default = numrange[0]
else:
default = display_letters[0].lower()
# Make a prompt if one is not provided.
if not prompt:
prompt_parts = []
@@ -227,16 +261,14 @@ def input_options(options, require=False, prompt=None, fallback_prompt=None,
fallback_prompt += '%i-%i, ' % numrange
fallback_prompt += ', '.join(display_letters) + ':'
# (raw_input(prompt) was causing problems with colors.)
print prompt,
resp = raw_input()
resp = input_(prompt)
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:
@@ -249,16 +281,15 @@ def input_options(options, require=False, prompt=None, fallback_prompt=None,
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()
resp = input_(fallback_prompt)
def input_yn(prompt, require=False, color=False):
"""Prompts the user for a "yes" or "no" response. The default is
@@ -277,7 +308,7 @@ def config_val(config, section, name, default, vtype=None):
"""
if not config.has_section(section):
config.add_section(section)
try:
if vtype is bool:
return config.getboolean(section, name)
@@ -371,7 +402,7 @@ def colordiff(a, b, highlight='red'):
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':
@@ -390,7 +421,7 @@ def colordiff(a, b, highlight='red'):
b_out.append(colorize(highlight, b[b_start:b_end]))
else:
assert(False)
return u''.join(a_out), u''.join(b_out)
def default_paths(pathmod=None):
@@ -428,6 +459,8 @@ def _get_replacements(config):
repl_string = config_val(config, 'beets', 'replace', None)
if not repl_string:
return
if not isinstance(repl_string, unicode):
repl_string = repl_string.decode('utf8')
parts = repl_string.strip().split()
if not parts:
@@ -453,10 +486,12 @@ def _get_path_formats(config):
legacy_path_format = config_val(config, 'beets', 'path_format', None)
if legacy_path_format:
# Old path formats override the default values.
path_formats = [(library.PF_KEY_DEFAULT, legacy_path_format)]
path_formats = [(library.PF_KEY_DEFAULT,
Template(legacy_path_format))]
else:
# If no legacy path format, use the defaults instead.
path_formats = DEFAULT_PATH_FORMATS
if config.has_section('paths'):
custom_path_formats = []
for key, value in config.items('paths', True):
@@ -467,8 +502,9 @@ def _get_path_formats(config):
# For non-special keys (literal queries), the _
# character denotes a :.
key = key.replace('_', ':')
custom_path_formats.append((key, value))
custom_path_formats.append((key, Template(value)))
path_formats = custom_path_formats + path_formats
return path_formats
@@ -504,7 +540,7 @@ class SubcommandsOptionParser(optparse.OptionParser):
_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
@@ -513,41 +549,41 @@ class SubcommandsOptionParser(optparse.OptionParser):
# 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.
# 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 = []
@@ -557,12 +593,12 @@ class SubcommandsOptionParser(optparse.OptionParser):
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)
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.
@@ -581,11 +617,11 @@ class SubcommandsOptionParser(optparse.OptionParser):
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
@@ -596,16 +632,16 @@ class SubcommandsOptionParser(optparse.OptionParser):
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()
@@ -615,7 +651,7 @@ class SubcommandsOptionParser(optparse.OptionParser):
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:
@@ -623,13 +659,15 @@ class SubcommandsOptionParser(optparse.OptionParser):
# particular
cmdname = subargs[0]
helpcommand = self._subcommand_for_name(cmdname)
if not helpcommand:
self.error('no command named {0}'.format(cmdname))
helpcommand.parser.print_help()
self.exit()
else:
# general
self.print_help()
self.exit()
return options, subcommand, suboptions, subargs
@@ -654,7 +692,7 @@ def main(args=None, configfh=None):
if configpath:
configpath = util.syspath(configpath)
if os.path.exists(util.syspath(configpath)):
configfh = open(configpath)
configfh = codecs.open(configpath, 'r', encoding='utf-8')
else:
configfh = None
if configfh:
@@ -667,7 +705,6 @@ def main(args=None, configfh=None):
# Load requested plugins.
plugnames = config_val(config, 'beets', 'plugins', '')
plugins.load_plugins(plugnames.split())
plugins.load_listeners()
plugins.send("pluginload")
plugins.configure(config)
@@ -681,10 +718,10 @@ def main(args=None, configfh=None):
help="destination music directory")
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_libpath)
@@ -709,7 +746,8 @@ def main(args=None, configfh=None):
replacements)
except sqlite3.OperationalError:
raise UserError("database file %s could not be opened" % db_path)
plugins.send("library_opened", lib=lib)
# Configure the logger.
log = logging.getLogger('beets')
if options.verbose:
@@ -719,14 +757,17 @@ def main(args=None, configfh=None):
log.debug(u'config file: %s' % util.displayable_path(configpath))
log.debug(u'library database: %s' % util.displayable_path(lib.path))
log.debug(u'library directory: %s' % util.displayable_path(lib.directory))
# Invoke the subcommand.
try:
subcommand.func(lib, config, suboptions, subargs)
except UserError, exc:
except UserError as exc:
message = exc.args[0] if exc.args else None
subcommand.parser.error(message)
except IOError, exc:
except util.HumanReadableException as exc:
exc.log(log)
sys.exit(1)
except IOError as exc:
if exc.errno == errno.EPIPE:
# "Broken pipe". End silently.
pass
+287 -247
View File
@@ -1,5 +1,5 @@
# This file is part of beets.
# Copyright 2011, Adrian Sampson.
# Copyright 2012, Adrian Sampson.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
@@ -8,25 +8,25 @@
# 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 provides the default commands for beets' command-line
interface.
"""
from __future__ import with_statement # Python 2.5
from __future__ import print_function
import logging
import sys
import os
import time
import itertools
import re
import lib.beets
from lib.beets import ui
from lib.beets.ui import print_, decargs
from lib.beets.ui import print_, input_, decargs
from lib.beets import autotag
import lib.beets.autotag.art
from lib.beets import plugins
from lib.beets import importer
from lib.beets.util import syspath, normpath, ancestry, displayable_path
@@ -64,7 +64,7 @@ def _do_query(lib, query, album, also_items=True):
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
@@ -84,14 +84,27 @@ def _showdiff(field, oldval, newval, color):
print_(u' %s: %s -> %s' % (field, oldval, newval))
# fields: Shows a list of available fields for queries and format strings.
fields_cmd = ui.Subcommand('fields',
help='show fields available for queries and format strings')
def fields_func(lib, config, opts, args):
print("Available item fields:")
print(" " + "\n ".join([key for key in library.ITEM_KEYS]))
print("\nAvailable album fields:")
print(" " + "\n ".join([key for key in library.ALBUM_KEYS]))
fields_cmd.func = fields_func
default_commands.append(fields_cmd)
# import: Autotagger and importer.
DEFAULT_IMPORT_COPY = True
DEFAULT_IMPORT_MOVE = False
DEFAULT_IMPORT_WRITE = True
DEFAULT_IMPORT_DELETE = False
DEFAULT_IMPORT_AUTOT = True
DEFAULT_IMPORT_TIMID = False
DEFAULT_IMPORT_ART = True
DEFAULT_IMPORT_QUIET = False
DEFAULT_IMPORT_QUIET_FALLBACK = 'skip'
DEFAULT_IMPORT_RESUME = None # "ask"
@@ -101,6 +114,7 @@ DEFAULT_COLOR = True
DEFAULT_IGNORE = [
'.*', '*~',
]
DEFAULT_PER_DISC_NUMBERING = False
VARIOUS_ARTISTS = u'Various Artists'
@@ -122,10 +136,11 @@ def dist_string(dist, color):
out = ui.colorize('red', out)
return out
def show_change(cur_artist, cur_album, items, info, dist, color=True):
"""Print out a representation of the changes that will be made if
tags are changed from (cur_artist, cur_album, items) to info with
distance dist.
def show_change(cur_artist, cur_album, match, color=True,
per_disc_numbering=False):
"""Print out a representation of the changes that will be made if an
album's tags are changed according to `match`, which must be an AlbumMatch
object.
"""
def show_album(artist, album, partial=False):
if artist:
@@ -148,14 +163,25 @@ def show_change(cur_artist, cur_album, items, info, dist, color=True):
out += u' ' + warning
print_(out)
# Record if the match is partial or not.
partial_match = None in items
def format_index(track_info):
"""Return a string representing the track index of the given
TrackInfo object.
"""
if per_disc_numbering:
if match.info.mediums > 1:
return u'{0}-{1}'.format(track_info.medium,
track_info.medium_index)
else:
return unicode(track_info.medium_index)
else:
return unicode(track_info.index)
# 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 != match.info.artist or \
(cur_album != match.info.album and
match.info.album != VARIOUS_ARTISTS):
artist_l, artist_r = cur_artist or '', match.info.artist
album_l, album_r = cur_album or '', match.info.album
if artist_r == VARIOUS_ARTISTS:
# Hide artists for VA releases.
artist_l, artist_r = u'', u''
@@ -169,8 +195,8 @@ def show_change(cur_artist, cur_album, items, info, dist, color=True):
print_("To:")
show_album(artist_r, album_r)
else:
message = u"Tagging: %s - %s" % (info.artist, info.album)
if partial_match:
message = u"Tagging: %s - %s" % (match.info.artist, match.info.album)
if match.extra_items or match.extra_tracks:
warning = PARTIAL_MATCH_MESSAGE
if color:
warning = ui.colorize('yellow', PARTIAL_MATCH_MESSAGE)
@@ -178,18 +204,17 @@ def show_change(cur_artist, cur_album, items, info, dist, color=True):
print_(message)
# Distance/similarity.
print_('(Similarity: %s)' % dist_string(dist, color))
print_('(Similarity: %s)' % dist_string(match.distance, color))
# Tracks.
missing_tracks = []
for i, (item, track_info) in enumerate(zip(items, info.tracks)):
if not item:
missing_tracks.append((i, track_info))
continue
pairs = match.mapping.items()
pairs.sort(key=lambda (_, track_info): track_info.index)
for item, track_info in pairs:
# Get displayable LHS and RHS values.
cur_track = unicode(item.track)
new_track = unicode(i+1)
new_track = format_index(track_info)
tracks_differ = item.track not in (track_info.index,
track_info.medium_index)
cur_title = item.title
new_title = track_info.title
if item.length and track_info.length:
@@ -198,48 +223,55 @@ def show_change(cur_artist, cur_album, items, info, dist, color=True):
if color:
cur_length = ui.colorize('red', cur_length)
new_length = ui.colorize('red', new_length)
# Possibly colorize changes.
if color:
cur_title, new_title = ui.colordiff(cur_title, new_title)
if cur_track != new_track:
cur_track = ui.colorize('red', cur_track)
new_track = ui.colorize('red', 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 = displayable_path(os.path.basename(item.path))
if cur_title != new_title:
lhs, rhs = cur_title, new_title
if cur_track != new_track:
if tracks_differ:
lhs += u' (%s)' % cur_track
rhs += u' (%s)' % new_track
print_(u" * %s -> %s" % (lhs, rhs))
else:
line = u' * %s' % item.title
display = False
if cur_track != new_track:
if tracks_differ:
display = True
line += u' (%s -> %s)' % (cur_track, new_track)
if item.length and track_info.length and \
abs(item.length - track_info.length) > 2.0:
display = True
line += u' (%s -> %s)' % (cur_length, new_length)
line += u' (%s vs. %s)' % (cur_length, new_length)
if display:
print_(line)
for i, track_info in missing_tracks:
line = u' * Missing track: %s (%d)' % (track_info.title, i+1)
# Missing and unmatched tracks.
for track_info in match.extra_tracks:
line = u' * Missing track: {0} ({1})'.format(track_info.title,
format_index(track_info))
if color:
line = ui.colorize('yellow', line)
print_(line)
for item in match.extra_items:
line = u' * Unmatched track: {0} ({1})'.format(item.title, item.track)
if color:
line = ui.colorize('yellow', line)
print_(line)
def show_item_change(item, info, dist, color):
def show_item_change(item, match, color):
"""Print out the change that would occur by tagging `item` with the
metadata from `info`.
metadata from `match`, a TrackMatch object.
"""
cur_artist, new_artist = item.artist, info.artist
cur_title, new_title = item.title, info.title
cur_artist, new_artist = item.artist, match.info.artist
cur_title, new_title = item.title, match.info.title
if cur_artist != new_artist or cur_title != new_title:
if color:
@@ -254,7 +286,7 @@ def show_item_change(item, info, dist, color):
else:
print_("Tagging track: %s - %s" % (cur_artist, cur_title))
print_('(Similarity: %s)' % dist_string(dist, color))
print_('(Similarity: %s)' % dist_string(match.distance, color))
def should_resume(config, path):
return ui.input_yn("Import of the directory:\n%s"
@@ -273,17 +305,17 @@ def _quiet_fall_back(config):
return config.quiet_fallback
def choose_candidate(candidates, singleton, rec, color, timid,
cur_artist=None, cur_album=None, item=None):
cur_artist=None, cur_album=None, item=None,
itemcount=None, per_disc_numbering=False):
"""Given a sorted list of candidates, ask the user for a selection
of which candidate to use. Applies to both full albums and
singletons (tracks). For albums, the candidates are `(dist, items,
info)` triples and `cur_artist` and `cur_album` must be provided.
For singletons, the candidates are `(dist, info)` pairs and `item`
must be provided.
of which candidate to use. Applies to both full albums and
singletons (tracks). Candidates are either AlbumMatch or TrackMatch
objects depending on `singleton`. for albums, `cur_artist`,
`cur_album`, and `itemcount` must be provided. For singletons,
`item` must be provided.
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 a TrackInfo object.
MANUAL or a candidate (an AlbumMatch/TrackMatch object).
"""
# Sanity check.
if singleton:
@@ -294,11 +326,15 @@ def choose_candidate(candidates, singleton, rec, color, timid,
# Zero candidates.
if not candidates:
print_("No match found.")
if singleton:
print_("No matching recordings found.")
opts = ('Use as-is', 'Skip', 'Enter search', 'enter Id',
'aBort')
else:
print_("No matching release found for {0} tracks."
.format(itemcount))
print_('For help, see: '
'https://github.com/sampsyo/beets/wiki/FAQ#wiki-nomatch')
opts = ('Use as-is', 'as Tracks', 'Skip', 'Enter search',
'enter Id', 'aBort')
sel = ui.input_options(opts, color=color)
@@ -321,12 +357,9 @@ def choose_candidate(candidates, singleton, rec, color, timid,
# Is the change good enough?
bypass_candidates = False
if rec != autotag.RECOMMEND_NONE:
if singleton:
dist, info = candidates[0]
else:
dist, items, info = candidates[0]
match = candidates[0]
bypass_candidates = True
while True:
# Display and choose from candidates.
if not bypass_candidates:
@@ -335,22 +368,24 @@ def choose_candidate(candidates, singleton, rec, color, timid,
print_('Finding tags for track "%s - %s".' %
(item.artist, item.title))
print_('Candidates:')
for i, (dist, info) in enumerate(candidates):
print_('%i. %s - %s (%s)' % (i+1, info.artist,
info.title, dist_string(dist, color)))
for i, match in enumerate(candidates):
print_('%i. %s - %s (%s)' %
(i + 1, match.info.artist, match.info.title,
dist_string(match.distance, color)))
else:
print_('Finding tags for album "%s - %s".' %
(cur_artist, cur_album))
print_('Candidates:')
for i, (dist, items, info) in enumerate(candidates):
line = '%i. %s - %s' % (i+1, info.artist, info.album)
for i, match in enumerate(candidates):
line = '%i. %s - %s' % (i + 1, match.info.artist,
match.info.album)
# Label and year disambiguation, if available.
label, year = None, None
if info.label:
label = info.label
if info.year:
year = unicode(info.year)
if match.info.label:
label = match.info.label
if match.info.year:
year = unicode(match.info.year)
if label and year:
line += u' [%s, %s]' % (label, year)
elif label:
@@ -358,17 +393,17 @@ def choose_candidate(candidates, singleton, rec, color, timid,
elif year:
line += u' [%s]' % year
line += ' (%s)' % dist_string(dist, color)
line += ' (%s)' % dist_string(match.distance, color)
# Point out the partial matches.
if None in items:
if match.extra_items or match.extra_tracks:
warning = PARTIAL_MATCH_MESSAGE
if color:
warning = ui.colorize('yellow', warning)
line += u' %s' % warning
print_(line)
# Ask the user for a choice.
if singleton:
opts = ('Skip', 'Use as-is', 'Enter search', 'enter Id',
@@ -391,26 +426,24 @@ def choose_candidate(candidates, singleton, rec, color, timid,
raise importer.ImportAbort()
elif sel == 'i':
return importer.action.MANUAL_ID
else: # Numerical selection.
else: # Numerical selection.
if singleton:
dist, info = candidates[sel-1]
match = candidates[sel - 1]
else:
dist, items, info = candidates[sel-1]
match = candidates[sel - 1]
bypass_candidates = False
# Show what we're about to do.
if singleton:
show_item_change(item, info, dist, color)
show_item_change(item, match, color)
else:
show_change(cur_artist, cur_album, items, info, dist, color)
show_change(cur_artist, cur_album, match, color,
per_disc_numbering)
# Exact match => tag automatically if we're not in timid mode.
if rec == autotag.RECOMMEND_STRONG and not timid:
if singleton:
return info
else:
return info, items
return match
# Ask for confirmation.
if singleton:
opts = ('Apply', 'More candidates', 'Skip', 'Use as-is',
@@ -420,10 +453,7 @@ def choose_candidate(candidates, singleton, rec, color, timid,
'as Tracks', 'Enter search', 'enter Id', 'aBort')
sel = ui.input_options(opts, color=color)
if sel == 'a':
if singleton:
return info
else:
return info, items
return match
elif sel == 'm':
pass
elif sel == 's':
@@ -444,18 +474,17 @@ def manual_search(singleton):
"""Input either an artist and album (for full albums) or artist and
track name (for singletons) for manual search.
"""
artist = raw_input('Artist: ').decode(sys.stdin.encoding)
name = raw_input('Track: ' if singleton else 'Album: ') \
.decode(sys.stdin.encoding)
artist = input_('Artist:')
name = input_('Track:' if singleton else 'Album:')
return artist.strip(), name.strip()
def manual_id(singleton):
"""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: ' % \
prompt = 'Enter MusicBrainz %s ID:' % \
('recording' if singleton else 'release')
entry = raw_input(prompt).decode(sys.stdin.encoding).strip()
entry = input_(prompt).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)
@@ -468,7 +497,7 @@ def manual_id(singleton):
def choose_match(task, config):
"""Given an initial autotagging of items, go through an interactive
dance with the user to ask for a choice of metadata. Returns an
(info, items) pair, ASIS, or SKIP.
AlbumMatch object, ASIS, or SKIP.
"""
# Show what we're tagging.
print_()
@@ -477,10 +506,9 @@ def choose_match(task, config):
if config.quiet:
# No input; just make a decision.
if task.rec == autotag.RECOMMEND_STRONG:
dist, items, info = task.candidates[0]
show_change(task.cur_artist, task.cur_album, items, info, dist,
config.color)
return info, items
match = task.candidates[0]
show_change(task.cur_artist, task.cur_album, match, config.color)
return match
else:
return _quiet_fall_back(config)
@@ -488,10 +516,11 @@ def choose_match(task, config):
candidates, rec = task.candidates, task.rec
while True:
# Ask for a choice from the user.
choice = choose_candidate(candidates, False, rec, config.color,
choice = choose_candidate(candidates, False, rec, config.color,
config.timid, task.cur_artist,
task.cur_album)
task.cur_album, itemcount=len(task.items),
per_disc_numbering=config.per_disc_numbering)
# Choose which tags to use.
if choice in (importer.action.SKIP, importer.action.ASIS,
importer.action.TRACKS):
@@ -517,25 +546,25 @@ def choose_match(task, config):
except autotag.AutotagError:
candidates, rec = None, None
else:
# We have a candidate! Finish tagging. Here, choice is
# an (info, items) pair as desired.
assert not isinstance(choice, importer.action)
# We have a candidate! Finish tagging. Here, choice is an
# AlbumMatch object.
assert isinstance(choice, autotag.AlbumMatch)
return choice
def choose_item(task, config):
"""Ask the user for a choice about tagging a single item. Returns
either an action constant or a TrackInfo object.
either an action constant or a TrackMatch object.
"""
print_()
print_(task.item.path)
candidates, rec = task.item_match
candidates, rec = task.candidates, task.rec
if config.quiet:
# Quiet mode; make a decision.
if rec == autotag.RECOMMEND_STRONG:
dist, track_info = candidates[0]
show_item_change(task.item, track_info, dist, config.color)
return track_info
match = candidates[0]
show_item_change(task.item, match, config.color)
return match
else:
return _quiet_fall_back(config)
@@ -558,10 +587,10 @@ def choose_item(task, config):
search_id = manual_id(True)
if search_id:
candidates, rec = autotag.tag_item(task.item, config.timid,
search_id=search_id)
search_id=search_id)
else:
# Chose a candidate.
assert not isinstance(choice, importer.action)
assert isinstance(choice, autotag.TrackMatch)
return choice
def resolve_duplicate(task, config):
@@ -595,30 +624,30 @@ def resolve_duplicate(task, config):
# The import command.
def import_files(lib, paths, copy, write, autot, logpath, art, threaded,
def import_files(lib, paths, copy, move, write, autot, logpath, threaded,
color, delete, quiet, resume, quiet_fallback, singletons,
timid, query, incremental, ignore):
timid, query, incremental, ignore, per_disc_numbering):
"""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
files themselves. If not autot, then just import the files
without attempting to tag. If logpath is provided, then untaggable
albums will be logged there. If art, then attempt to download
cover art for each album. If threaded, then accelerate autotagging
directory as an album. If copy, then the files are copied into the
library folder. If write, then new metadata is written to the files
themselves. If not autot, then just import the files without
attempting to tag. If logpath is provided, then untaggable albums
will be logged there. If threaded, then accelerate autotagging
imports by running them in multiple threads. If color, then
ANSI-colorize some terminal output. If delete, then old files are
deleted when they are copied. If quiet, then the user is
never prompted for input; instead, the tagger just skips anything
it is not confident about. resume indicates whether interrupted
imports can be resumed and is either a boolean or None.
quiet_fallback should be either ASIS or SKIP and indicates what
should happen in quiet mode when the recommendation is not strong.
deleted when they are copied. If quiet, then the user is never
prompted for input; instead, the tagger just skips anything it is
not confident about. resume indicates whether interrupted imports
can be resumed and is either a boolean or None. quiet_fallback
should be either ASIS or SKIP and indicates what should happen in
quiet mode when the recommendation is not strong.
"""
# Check the user-specified directories.
for path in paths:
if not singletons and not os.path.isdir(syspath(path)):
fullpath = syspath(normpath(path))
if not singletons and not os.path.isdir(fullpath):
raise ui.UserError('not a directory: ' + path)
elif singletons and not os.path.exists(syspath(path)):
elif singletons and not os.path.exists(fullpath):
raise ui.UserError('no such file: ' + path)
# Check parameter consistency.
@@ -633,7 +662,7 @@ def import_files(lib, paths, copy, write, autot, logpath, art, threaded,
except IOError:
raise ui.UserError(u"could not open log file for writing: %s" %
displayable_path(logpath))
print >>logfile, 'import started', time.asctime()
print('import started', time.asctime(), file=logfile)
else:
logfile = None
@@ -652,8 +681,8 @@ def import_files(lib, paths, copy, write, autot, logpath, art, threaded,
quiet = quiet,
quiet_fallback = quiet_fallback,
copy = copy,
move = move,
write = write,
art = art,
delete = delete,
threaded = threaded,
autot = autot,
@@ -666,12 +695,13 @@ def import_files(lib, paths, copy, write, autot, logpath, art, threaded,
incremental = incremental,
ignore = ignore,
resolve_duplicate_func = resolve_duplicate,
per_disc_numbering = per_disc_numbering,
)
finally:
# If we were logging, close the file.
if logfile:
print >>logfile, ''
print('', file=logfile)
logfile.close()
# Emit event.
@@ -696,10 +726,6 @@ import_cmd.parser.add_option('-p', '--resume', action='store_true',
default=None, help="resume importing if interrupted")
import_cmd.parser.add_option('-P', '--noresume', action='store_false',
dest='resume', help="do not try to resume importing")
import_cmd.parser.add_option('-r', '--art', action='store_true',
default=None, help="try to download album art")
import_cmd.parser.add_option('-R', '--noart', action='store_false',
dest='art', help="don't album art (opposite of -r)")
import_cmd.parser.add_option('-q', '--quiet', action='store_true',
dest='quiet', help="never prompt for input: skip albums instead")
import_cmd.parser.add_option('-l', '--log', dest='logpath',
@@ -712,19 +738,20 @@ 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')
import_cmd.parser.add_option('-I', '--noincremental', dest='incremental',
action='store_false', help='do not 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',
DEFAULT_IMPORT_COPY, bool)
move = ui.config_val(config, 'beets', 'import_move',
DEFAULT_IMPORT_MOVE, bool)
write = opts.write if opts.write is not None else \
ui.config_val(config, 'beets', 'import_write',
DEFAULT_IMPORT_WRITE, bool)
delete = ui.config_val(config, 'beets', 'import_delete',
DEFAULT_IMPORT_DELETE, bool)
autot = opts.autotag if opts.autotag is not None else DEFAULT_IMPORT_AUTOT
art = opts.art if opts.art is not None else \
ui.config_val(config, 'beets', 'import_art',
DEFAULT_IMPORT_ART, bool)
threaded = ui.config_val(config, 'beets', 'threaded',
DEFAULT_THREADED, bool)
color = ui.config_val(config, 'beets', 'color', DEFAULT_COLOR, bool)
@@ -741,6 +768,8 @@ def import_func(lib, config, opts, args):
ui.config_val(config, 'beets', 'import_incremental',
DEFAULT_IMPORT_INCREMENTAL, bool)
ignore = ui.config_val(config, 'beets', 'ignore', DEFAULT_IGNORE, list)
per_disc_numbering = ui.config_val(config, 'beets', 'per_disc_numbering',
DEFAULT_PER_DISC_NUMBERING, bool)
# Resume has three options: yes, no, and "ask" (None).
resume = opts.resume if opts.resume is not None else \
@@ -753,6 +782,11 @@ def import_func(lib, config, opts, args):
else:
resume = None
# Special case: --copy flag suppresses import_move (which would
# otherwise take precedence).
if opts.copy:
move = False
if quiet_fallback_str == 'asis':
quiet_fallback = importer.action.ASIS
else:
@@ -765,26 +799,23 @@ def import_func(lib, config, opts, args):
query = None
paths = args
import_files(lib, paths, copy, write, autot, logpath, art, threaded,
import_files(lib, paths, copy, move, write, autot, logpath, threaded,
color, delete, quiet, resume, quiet_fallback, singletons,
timid, query, incremental, ignore)
timid, query, incremental, ignore, per_disc_numbering)
import_cmd.func = import_func
default_commands.append(import_cmd)
# list: Query and show library contents.
DEFAULT_LIST_FORMAT_ITEM = '$artist - $album - $title'
DEFAULT_LIST_FORMAT_ALBUM = '$albumartist - $album'
def list_items(lib, query, album, path, fmt):
"""Print out items in lib matching query. If album, then search for
albums instead of single items. If path, print the matched objects'
paths instead of human-readable information about them.
"""
if fmt is None:
# If no specific template is supplied, use a default.
if album:
fmt = u'$albumartist - $album'
else:
fmt = u'$artist - $album - $title'
template = Template(fmt)
if album:
@@ -792,13 +823,13 @@ def list_items(lib, query, album, path, fmt):
if path:
print_(album.item_dir())
elif fmt is not None:
print_(template.substitute(album._record))
print_(album.evaluate_template(template))
else:
for item in lib.items(query):
if path:
print_(item.path)
elif fmt is not None:
print_(template.substitute(item.record))
print_(item.evaluate_template(template, lib))
list_cmd = ui.Subcommand('list', help='query the library', aliases=('ls',))
list_cmd.parser.add_option('-a', '--album', action='store_true',
@@ -808,7 +839,16 @@ list_cmd.parser.add_option('-p', '--path', action='store_true',
list_cmd.parser.add_option('-f', '--format', action='store',
help='print with custom format', default=None)
def list_func(lib, config, opts, args):
list_items(lib, decargs(args), opts.album, opts.path, opts.format)
fmt = opts.format
if not fmt:
# If no format is specified, fall back to a default.
if opts.album:
fmt = ui.config_val(config, 'beets', 'list_format_album',
DEFAULT_LIST_FORMAT_ALBUM)
else:
fmt = ui.config_val(config, 'beets', 'list_format_item',
DEFAULT_LIST_FORMAT_ITEM)
list_items(lib, decargs(args), opts.album, opts.path, fmt)
list_cmd.func = list_func
default_commands.append(list_cmd)
@@ -819,89 +859,89 @@ 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)
with lib.transaction():
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
# Did the item change since last checked?
if item.current_mtime() <= item.mtime:
log.debug(u'skipping %s because mtime is up to date (%i)' %
(displayable_path(item.path), item.mtime))
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:
# 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
# Move the item if it's in the library.
if move and lib.directory in ancestry(item.path):
lib.move(item)
# Did the item change since last checked?
if item.current_mtime() <= item.mtime:
log.debug(u'skipping %s because mtime is up to date (%i)' %
(displayable_path(item.path), item.mtime))
continue
lib.store(item)
affected_albums.add(item.album_id)
elif not pretend:
# The file's mtime was different, but there were no changes
# to the metadata. Store the new mtime, which is set in the
# call to read(), so we don't check this again in the
# future.
lib.store(item)
# Read new data.
old_data = dict(item.record)
item.read()
# Skip album changes while pretending.
if pretend:
return
# 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
# 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())
# 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)
# Update album structure to reflect an item in it.
for key in library.ALBUM_KEYS_ITEM:
setattr(album, key, getattr(al_items[0], key))
# If we're just pretending, then don't move or save.
if pretend:
continue
# 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()
# Move the item if it's in the library.
if move and lib.directory in ancestry(item.path):
lib.move(item)
lib.save()
lib.store(item)
affected_albums.add(item.album_id)
elif not pretend:
# The file's mtime was different, but there were no changes
# to the metadata. Store the new mtime, which is set in the
# call to read(), so we don't check this again in the
# future.
lib.store(item)
# 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()
update_cmd = ui.Subcommand('update',
help='update the library', aliases=('upd','up',))
@@ -942,14 +982,13 @@ def remove_items(lib, query, album, delete=False):
return
# Remove (and possibly delete) items.
if album:
for al in albums:
al.remove(delete)
else:
for item in items:
lib.remove(item, delete)
lib.save()
with lib.transaction():
if album:
for al in albums:
al.remove(delete)
else:
for item in items:
lib.remove(item, delete)
remove_cmd = ui.Subcommand('remove',
help='remove matching items from the library', aliases=('rm',))
@@ -1007,16 +1046,18 @@ default_commands.append(stats_cmd)
# version: Show current beets version.
def show_version(lib, config, opts, args):
print 'beets version %s' % lib.beets.__version__
print_('beets version %s' % lib.beets.__version__)
# Show plugins.
names = []
for plugin in plugins.find_plugins():
modname = plugin.__module__
names.append(modname.split('.')[-1])
if names:
print 'plugins:', ', '.join(names)
print_('plugins:', ', '.join(names))
else:
print 'no plugins loaded'
print_('no plugins loaded')
version_cmd = ui.Subcommand('version',
help='output version information')
version_cmd.func = show_version
@@ -1061,23 +1102,23 @@ def modify_items(lib, mods, query, write, move, album, color, confirm):
return
# Apply changes to database.
for obj in objs:
for field, value in fsets.iteritems():
setattr(obj, field, value)
with lib.transaction():
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)
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()
# When modifying items, we have to store them to the database.
if not album:
lib.store(obj)
# Apply tags if requested.
if write:
@@ -1136,7 +1177,6 @@ def move_items(lib, dest, query, copy, album):
else:
lib.move(obj, copy, basedir=dest)
lib.store(obj)
lib.save()
move_cmd = ui.Subcommand('move',
help='move or copy items', aliases=('mv',))