initial autotagger output coloring (of titles and tracks only)

This commit is contained in:
Adrian Sampson 2010-08-01 15:35:11 -07:00
parent 957b414f97
commit 1b5a2afd35
3 changed files with 101 additions and 23 deletions

6
NEWS
View file

@ -12,6 +12,12 @@
threaded) version is still available by setting the "threaded"
config value to "no" (because the parallel version is still quite
experimental).
* Colorized tagger output. The autotagger interface now makes it a
little easier to see what's going on at a glance by highlighting
changes with terminal colors. This feature is on by default, but you
can turn it off by setting "color" to "no" in your .beetsconfig (if,
for example, your terminal doesn't understand colors and garbles the
output).
* Fixed a bug where the CLI would fail completely if the LANG
environment variable was not set.
* Fixed removal of albums (beet remove -a): previously, the album

View file

@ -23,6 +23,7 @@ import optparse
import textwrap
import ConfigParser
import sys
from difflib import SequenceMatcher
from beets import library
from beets import plugins
@ -172,6 +173,56 @@ def human_seconds(interval):
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 strings, return the same pair of strings except with
their differences highlighted in the specified color.
"""
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 ''.join(a_out), ''.join(b_out)
# Subcommand parsing infrastructure.

View file

@ -18,8 +18,6 @@ interface.
import os
import logging
from threading import Thread
from Queue import Queue
from beets import ui
from beets.ui import print_
@ -44,38 +42,54 @@ DEFAULT_IMPORT_WRITE = True
DEFAULT_IMPORT_AUTOT = True
DEFAULT_IMPORT_ART = True
DEFAULT_THREADED = True
DEFAULT_COLOR = True
# Autotagger utilities and support.
def show_change(cur_artist, cur_album, items, info, dist):
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.
"""
if cur_artist != info['artist'] or cur_album != info['album']:
artist_l, artist_r = cur_artist or '', info['artist']
album_l, album_r = cur_album or '', info['album']
if color:
artist_l, artist_r = ui.colordiff(artist_l, artist_r)
album_l, album_r = ui.colordiff(album_l, album_r)
print_("Correcting tags from:")
print_(' %s - %s' % (cur_artist or '', cur_album or ''))
print_(' %s - %s' % (artist_l, album_l))
print_("To:")
print_(' %s - %s' % (info['artist'], info['album']))
print_(' %s - %s' % (artist_r, album_r))
else:
print_("Tagging: %s - %s" % (info['artist'], info['album']))
print_('(Distance: %f)' % dist)
for i, (item, track_data) in enumerate(zip(items, info['tracks'])):
cur_track = item.track
new_track = i+1
if item.title != track_data['title'] and cur_track != new_track:
print_(" * %s (%i) -> %s (%i)" % (
item.title, cur_track, track_data['title'], new_track
cur_track = str(item.track)
new_track = str(i+1)
cur_title = item.title
new_title = track_data['title']
# 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)
if cur_title != new_title and cur_track != new_track:
print_(" * %s (%s) -> %s (%s)" % (
cur_title, cur_track, new_title, new_track
))
elif item.title != track_data['title']:
print_(" * %s -> %s" % (item.title, track_data['title']))
elif cur_title != new_title:
print_(" * %s -> %s" % (cur_title, new_title))
elif cur_track != new_track:
print_(" * %s (%i -> %i)" % (item.title, cur_track, new_track))
print_(" * %s (%s -> %s)" % (item.title, cur_track, new_track))
CHOICE_SKIP = 'CHOICE_SKIP'
CHOICE_ASIS = 'CHOICE_ASIS'
CHOICE_MANUAL = 'CHOICE_MANUAL'
def choose_candidate(cur_artist, cur_album, candidates, rec):
def choose_candidate(cur_artist, cur_album, candidates, rec, color=True):
"""Given current metadata and a sorted list of
(distance, candidate) pairs, ask the user for a selection
of which candidate to use. Returns the selected candidate.
@ -117,7 +131,7 @@ def choose_candidate(cur_artist, cur_album, candidates, rec):
bypass_candidates = False
# Show what we're about to do.
show_change(cur_artist, cur_album, items, info, dist)
show_change(cur_artist, cur_album, items, info, dist, color)
# Exact match => tag automatically.
if rec == autotag.RECOMMEND_STRONG:
@ -155,7 +169,7 @@ def tag_log(logfile, status, items):
path = os.path.commonprefix([item.path for item in items])
print >>logfile, status, os.path.dirname(path)
def choose_match(items, cur_artist, cur_album, candidates, rec):
def choose_match(items, cur_artist, cur_album, candidates, rec, color=True):
"""Given an initial autotagging of items, go through an interactive
dance with the user to ask for a choice of metadata. Returns an
info dictionary, CHOICE_ASIS, or CHOICE_SKIP.
@ -164,7 +178,8 @@ def choose_match(items, cur_artist, cur_album, candidates, rec):
while True:
# Choose from candidates, if available.
if candidates:
info = choose_candidate(cur_artist, cur_album, candidates, rec)
info = choose_candidate(cur_artist, cur_album, candidates, rec,
color)
else:
# Fallback: if either an error ocurred or no matches found.
print_("No match found for:", os.path.dirname(items[0].path))
@ -242,7 +257,7 @@ def initial_lookup():
cur_artist, cur_album, candidates, rec = None, None, None, None
items = yield items, cur_artist, cur_album, candidates, rec
def user_query(lib, logfile=None):
def user_query(lib, logfile=None, color=True):
"""A coroutine for interfacing with the user about the tagging
process. lib is the Library to import into and logfile may be
a file-like object for logging the import process. The coroutine
@ -265,7 +280,8 @@ def user_query(lib, logfile=None):
first = False
# Ask the user for a choice.
info = choose_match(items, cur_artist, cur_album, candidates, rec)
info = choose_match(items, cur_artist, cur_album, candidates, rec,
color)
# The "give-up" options.
if info is CHOICE_ASIS:
@ -333,14 +349,17 @@ def apply_choices(lib, copy, write, art):
# The import command.
def import_files(lib, paths, copy, write, autot, logpath, art, threaded):
def import_files(lib, paths, copy, write, autot, logpath,
art, threaded, color):
"""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.
cover art for each album. If threaded, then accelerate autotagging
imports by running them in multiple threads. If color, then
ANSI-colorize some terminal output.
"""
# Open the log.
if logpath:
@ -354,7 +373,7 @@ def import_files(lib, paths, copy, write, autot, logpath, art, threaded):
pl = pipeline.Pipeline([
read_albums(paths),
initial_lookup(),
user_query(lib, logfile),
user_query(lib, logfile, color),
apply_choices(lib, copy, write, art),
])
if threaded:
@ -408,7 +427,9 @@ def import_func(lib, config, opts, args):
DEFAULT_IMPORT_ART, bool)
threaded = ui.config_val(config, 'beets', 'threaded',
DEFAULT_THREADED, bool)
import_files(lib, args, copy, write, autot, opts.logpath, art, threaded)
color = ui.config_val(config, 'beets', 'color', DEFAULT_COLOR, bool)
import_files(lib, args, copy, write, autot,
opts.logpath, art, threaded, color)
import_cmd.func = import_func
default_commands.append(import_cmd)