mirror of
https://github.com/beetbox/beets.git
synced 2026-01-07 08:32:06 +01:00
A large code-overhaul of the beets ui:
- Allow user to change UI colors in config file. - "Change Representation" class allows Albums and Track matches to reuse similar formatting code - Functions to split text into lines for printing - Tests for the new UI to check wrapping functions
This commit is contained in:
parent
821e6296ab
commit
be290e5444
4 changed files with 1000 additions and 318 deletions
|
|
@ -97,13 +97,34 @@ ui:
|
|||
length_diff_thresh: 10.0
|
||||
color: yes
|
||||
colors:
|
||||
text_success: green
|
||||
text_warning: yellow
|
||||
text_error: red
|
||||
text_highlight: red
|
||||
text_highlight_minor: lightgray
|
||||
action_default: turquoise
|
||||
action: blue
|
||||
text_success: ['bold', 'green']
|
||||
text_warning: ['bold', 'yellow']
|
||||
text_error: ['bold', 'red']
|
||||
text_highlight: ['bold', 'red']
|
||||
text_highlight_minor: ['white']
|
||||
action_default: ['bold', 'cyan']
|
||||
action: ['bold', 'blue']
|
||||
# New Colors
|
||||
text: ['normal']
|
||||
text_faint: ['faint']
|
||||
import_path: ['bold', 'inverse', 'blue']
|
||||
import_path_items: ['bold', 'blue']
|
||||
added: ['green']
|
||||
removed: ['red']
|
||||
changed: ['yellow']
|
||||
added_highlight: ['bold', 'green']
|
||||
removed_highlight: ['bold', 'red']
|
||||
changed_highlight: ['bold', 'yellow']
|
||||
text_diff_added: ['bold', 'red']
|
||||
text_diff_removed: ['bold', 'red']
|
||||
text_diff_changed: ['bold', 'red']
|
||||
action_description: ['blue']
|
||||
import:
|
||||
indentation:
|
||||
match_header: 2
|
||||
match_details: 2
|
||||
match_tracklist: 5
|
||||
layout: column
|
||||
|
||||
format_item: $artist - $album - $title
|
||||
format_album: $albumartist - $album
|
||||
|
|
|
|||
|
|
@ -184,6 +184,13 @@ def should_move(move_opt=None):
|
|||
|
||||
# Input prompts.
|
||||
|
||||
|
||||
def indent(count):
|
||||
"""Returns a string with `count` many spaces.
|
||||
"""
|
||||
return " " * count
|
||||
|
||||
|
||||
def input_(prompt=None):
|
||||
"""Like `input`, but decodes the result to a Unicode string.
|
||||
Raises a UserError if stdin is not available. The prompt is sent to
|
||||
|
|
@ -267,8 +274,11 @@ def input_options(options, require=False, prompt=None, fallback_prompt=None,
|
|||
show_letter)
|
||||
|
||||
# Insert the highlighted letter back into the word.
|
||||
descr_color = "action_default" if is_default else "action_description"
|
||||
capitalized.append(
|
||||
option[:index] + show_letter + option[index + 1:]
|
||||
colorize(descr_color, option[:index])
|
||||
+ show_letter
|
||||
+ colorize(descr_color, option[index + 1:])
|
||||
)
|
||||
display_letters.append(found_letter.upper())
|
||||
|
||||
|
|
@ -301,15 +311,16 @@ def input_options(options, require=False, prompt=None, fallback_prompt=None,
|
|||
prompt_part_lengths += [len(s) for s in options]
|
||||
|
||||
# Wrap the query text.
|
||||
prompt = ''
|
||||
# Start prompt with U+279C: Heavy Round-Tipped Rightwards Arrow
|
||||
prompt = colorize("action", "\u279C ")
|
||||
line_length = 0
|
||||
for i, (part, length) in enumerate(zip(prompt_parts,
|
||||
prompt_part_lengths)):
|
||||
# Add punctuation.
|
||||
if i == len(prompt_parts) - 1:
|
||||
part += '?'
|
||||
part += colorize("action_description", "?")
|
||||
else:
|
||||
part += ','
|
||||
part += colorize("action_description", ",")
|
||||
length += 1
|
||||
|
||||
# Choose either the current line or the beginning of the next.
|
||||
|
|
@ -368,10 +379,12 @@ def input_yn(prompt, require=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:'
|
||||
# Start prompt with U+279C: Heavy Round-Tipped Rightwards Arrow
|
||||
yesno = colorize("action", "\u279C ") + colorize(
|
||||
"action_description", "Enter Y or N:"
|
||||
)
|
||||
return sel == 'y'
|
||||
sel = input_options(("y", "n"), require, prompt, yesno)
|
||||
return sel == "y"
|
||||
|
||||
|
||||
def input_select_objects(prompt, objs, rep, prompt_all=None):
|
||||
|
|
@ -465,97 +478,200 @@ def human_seconds_short(interval):
|
|||
# https://bitbucket.org/birkenfeld/pygments-main/src/default/pygments/console.py
|
||||
# (pygments is by Tim Hatch, Armin Ronacher, et al.)
|
||||
COLOR_ESCAPE = "\x1b["
|
||||
DARK_COLORS = {
|
||||
"black": 0,
|
||||
"darkred": 1,
|
||||
"darkgreen": 2,
|
||||
"brown": 3,
|
||||
"darkyellow": 3,
|
||||
"darkblue": 4,
|
||||
"purple": 5,
|
||||
"darkmagenta": 5,
|
||||
"teal": 6,
|
||||
"darkcyan": 6,
|
||||
"lightgray": 7
|
||||
LEGACY_COLORS = {
|
||||
"black": ["black"],
|
||||
"darkred": ["red"],
|
||||
"darkgreen": ["green"],
|
||||
"brown": ["yellow"],
|
||||
"darkyellow": ["yellow"],
|
||||
"darkblue": ["blue"],
|
||||
"purple": ["magenta"],
|
||||
"darkmagenta": ["magenta"],
|
||||
"teal": ["cyan"],
|
||||
"darkcyan": ["cyan"],
|
||||
"lightgray": ["white"],
|
||||
"darkgray": ["bold", "black"],
|
||||
"red": ["bold", "red"],
|
||||
"green": ["bold", "green"],
|
||||
"yellow": ["bold", "yellow"],
|
||||
"blue": ["bold", "blue"],
|
||||
"fuchsia": ["bold", "magenta"],
|
||||
"magenta": ["bold", "magenta"],
|
||||
"turquoise": ["bold", "cyan"],
|
||||
"cyan": ["bold", "cyan"],
|
||||
"white": ["bold", "white"],
|
||||
}
|
||||
LIGHT_COLORS = {
|
||||
"darkgray": 0,
|
||||
"red": 1,
|
||||
"green": 2,
|
||||
"yellow": 3,
|
||||
"blue": 4,
|
||||
"fuchsia": 5,
|
||||
"magenta": 5,
|
||||
"turquoise": 6,
|
||||
"cyan": 6,
|
||||
"white": 7
|
||||
# All ANSI Colors.
|
||||
ANSI_CODES = {
|
||||
# Styles.
|
||||
"normal": 0,
|
||||
"bold": 1,
|
||||
"faint": 2,
|
||||
# "italic": 3,
|
||||
"underline": 4,
|
||||
# "blink_slow": 5,
|
||||
# "blink_rapid": 6,
|
||||
"inverse": 7,
|
||||
# "conceal": 8,
|
||||
# "crossed_out": 9
|
||||
# Text colors.
|
||||
"black": 30,
|
||||
"red": 31,
|
||||
"green": 32,
|
||||
"yellow": 33,
|
||||
"blue": 34,
|
||||
"magenta": 35,
|
||||
"cyan": 36,
|
||||
"white": 37,
|
||||
# Background colors.
|
||||
"bg_black": 40,
|
||||
"bg_red": 41,
|
||||
"bg_green": 42,
|
||||
"bg_yellow": 43,
|
||||
"bg_blue": 44,
|
||||
"bg_magenta": 45,
|
||||
"bg_cyan": 46,
|
||||
"bg_white": 47,
|
||||
}
|
||||
RESET_COLOR = COLOR_ESCAPE + "39;49;00m"
|
||||
|
||||
# These abstract COLOR_NAMES are lazily mapped on to the actual color in COLORS
|
||||
# as they are defined in the configuration files, see function: colorize
|
||||
COLOR_NAMES = ['text_success', 'text_warning', 'text_error', 'text_highlight',
|
||||
'text_highlight_minor', 'action_default', 'action']
|
||||
COLOR_NAMES = [
|
||||
"text_success",
|
||||
"text_warning",
|
||||
"text_error",
|
||||
"text_highlight",
|
||||
"text_highlight_minor",
|
||||
"action_default",
|
||||
"action",
|
||||
# New Colors
|
||||
"text",
|
||||
"text_faint",
|
||||
"import_path",
|
||||
"import_path_items",
|
||||
"action_description",
|
||||
"added",
|
||||
"removed",
|
||||
"changed",
|
||||
"added_highlight",
|
||||
"removed_highlight",
|
||||
"changed_highlight",
|
||||
"text_diff_added",
|
||||
"text_diff_removed",
|
||||
"text_diff_changed",
|
||||
]
|
||||
COLORS = None
|
||||
|
||||
|
||||
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.
|
||||
in a terminal that is ANSI color-aware. The color must be a list of strings
|
||||
from ANSI_CODES.
|
||||
"""
|
||||
if color in DARK_COLORS:
|
||||
escape = COLOR_ESCAPE + "%im" % (DARK_COLORS[color] + 30)
|
||||
elif color in LIGHT_COLORS:
|
||||
escape = COLOR_ESCAPE + "%i;01m" % (LIGHT_COLORS[color] + 30)
|
||||
else:
|
||||
raise ValueError('no such color %s', color)
|
||||
# Construct escape sequence to be put before the text by iterating
|
||||
# over all "ANSI codes" in `color`.
|
||||
escape = ""
|
||||
for code in color:
|
||||
escape = escape + COLOR_ESCAPE + "%im" % ANSI_CODES[code]
|
||||
return escape + text + RESET_COLOR
|
||||
|
||||
|
||||
def colorize(color_name, text):
|
||||
def colorize(color_name, text, whitespace=True):
|
||||
"""Colorize text if colored output is enabled. (Like _colorize but
|
||||
conditional.)
|
||||
"""
|
||||
if not config['ui']['color'] or 'NO_COLOR' in os.environ.keys():
|
||||
if config["ui"]["color"]:
|
||||
global COLORS
|
||||
if not COLORS:
|
||||
# Read all color configurations and set global variable COLORS.
|
||||
COLORS = dict()
|
||||
for name in COLOR_NAMES:
|
||||
# Convert legacy color definitions (strings) into the new
|
||||
# list-based color definitions. Do this by trying to read the
|
||||
# color definition from the configuration as unicode - if this
|
||||
# is successful, the color definition is a legacy definition
|
||||
# and has to be converted.
|
||||
try:
|
||||
color_def = config["ui"]["colors"][name].get(unicode)
|
||||
except (confuse.ConfigTypeError, NameError):
|
||||
# Normal color definition (type: list of unicode).
|
||||
color_def = config["ui"]["colors"][name].get(list)
|
||||
else:
|
||||
# Legacy color definition (type: unicode). Convert.
|
||||
if color_def in LEGACY_COLORS:
|
||||
color_def = LEGACY_COLORS[color_def]
|
||||
else:
|
||||
raise UserError("no such color %s", color_def)
|
||||
for code in color_def:
|
||||
if code not in ANSI_CODES.keys():
|
||||
raise ValueError("no such ANSI code %s", code)
|
||||
COLORS[name] = color_def
|
||||
# In case a 3rd party plugin is still passing the actual color ('red')
|
||||
# instead of the abstract color name ('text_error')
|
||||
color = COLORS.get(color_name)
|
||||
if not color:
|
||||
log.debug("Invalid color_name: {0}", color_name)
|
||||
color = color_name
|
||||
if whitespace:
|
||||
# Colorize including whitespaces
|
||||
return _colorize(color, text)
|
||||
else:
|
||||
# Split into words, then colorize individually
|
||||
return " ".join(_colorize(color, word)
|
||||
for word in text.split())
|
||||
else:
|
||||
return text
|
||||
|
||||
global COLORS
|
||||
if not COLORS:
|
||||
COLORS = {name:
|
||||
config['ui']['colors'][name].as_str()
|
||||
for name in COLOR_NAMES}
|
||||
# In case a 3rd party plugin is still passing the actual color ('red')
|
||||
# instead of the abstract color name ('text_error')
|
||||
color = COLORS.get(color_name)
|
||||
if not color:
|
||||
log.debug('Invalid color_name: {0}', color_name)
|
||||
color = color_name
|
||||
return _colorize(color, text)
|
||||
|
||||
def uncolorize(colored_text):
|
||||
"""Remove colors from a string."""
|
||||
# Define a regular expression to match ANSI codes.
|
||||
# See: http://stackoverflow.com/a/2187024/1382707
|
||||
# Explanation of regular expression:
|
||||
# \x1b - matches ESC character
|
||||
# \[ - matches opening square bracket
|
||||
# [;\d]* - matches a sequence consisting of one or more digits or
|
||||
# semicola
|
||||
# [A-Za-z] - matches a letter
|
||||
ansi_code_regex = re.compile(r"\x1b\[[;\d]*[A-Za-z]", re.VERBOSE)
|
||||
# Strip ANSI codes from `colored_text` using the regular expression.
|
||||
text = ansi_code_regex.sub("", colored_text)
|
||||
return text
|
||||
|
||||
|
||||
def _colordiff(a, b, highlight='text_highlight',
|
||||
minor_highlight='text_highlight_minor'):
|
||||
def color_len(colored_text):
|
||||
"""Measure the length of a string while excluding ANSI codes from the
|
||||
measurement. The standard `len(my_string)` method also counts ANSI codes
|
||||
to the string length, which is counterproductive when layouting a
|
||||
Terminal interface.
|
||||
"""
|
||||
# Return the length of the uncolored string.
|
||||
return len(uncolorize(colored_text))
|
||||
|
||||
|
||||
def _colordiff(a, b):
|
||||
"""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, str) \
|
||||
or not isinstance(b, str):
|
||||
# Non-strings: use ordinary equality.
|
||||
a = str(a)
|
||||
b = str(b)
|
||||
if a == b:
|
||||
return a, b
|
||||
else:
|
||||
return colorize(highlight, a), colorize(highlight, b)
|
||||
|
||||
# First, convert paths to readable format
|
||||
if isinstance(a, bytes) or isinstance(b, bytes):
|
||||
# A path field.
|
||||
a = util.displayable_path(a)
|
||||
b = util.displayable_path(b)
|
||||
|
||||
if not isinstance(a, str) or not isinstance(b, str):
|
||||
# Non-strings: use ordinary equality.
|
||||
if a == b:
|
||||
return str(a), str(b)
|
||||
else:
|
||||
return (
|
||||
colorize("text_diff_removed", str(a)),
|
||||
colorize("text_diff_added", str(b))
|
||||
)
|
||||
|
||||
a_out = []
|
||||
b_out = []
|
||||
|
||||
|
|
@ -566,32 +682,41 @@ def _colordiff(a, b, highlight='text_highlight',
|
|||
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]))
|
||||
# Right only. Colorize whitespace if added.
|
||||
b_out.append(colorize("text_diff_added",
|
||||
b[b_start:b_end],
|
||||
whitespace=True))
|
||||
elif op == 'delete':
|
||||
# Left only.
|
||||
a_out.append(colorize(highlight, a[a_start:a_end]))
|
||||
a_out.append(colorize("text_diff_removed",
|
||||
a[a_start:a_end],
|
||||
whitespace=False))
|
||||
elif op == 'replace':
|
||||
# Right and left differ. Colorise with second highlight if
|
||||
# it's just a case change.
|
||||
if a[a_start:a_end].lower() != b[b_start:b_end].lower():
|
||||
color = highlight
|
||||
a_color = "text_diff_removed"
|
||||
b_color = "text_diff_added"
|
||||
else:
|
||||
color = minor_highlight
|
||||
a_out.append(colorize(color, a[a_start:a_end]))
|
||||
b_out.append(colorize(color, b[b_start:b_end]))
|
||||
a_color = b_color = "text_highlight_minor"
|
||||
a_out.append(colorize(a_color,
|
||||
a[a_start:a_end],
|
||||
whitespace=False))
|
||||
b_out.append(colorize(b_color,
|
||||
b[b_start:b_end],
|
||||
whitespace=False))
|
||||
else:
|
||||
assert False
|
||||
|
||||
return ''.join(a_out), ''.join(b_out)
|
||||
|
||||
|
||||
def colordiff(a, b, highlight='text_highlight'):
|
||||
def colordiff(a, b):
|
||||
"""Colorize differences between two values if color is enabled.
|
||||
(Like _colordiff but conditional.)
|
||||
"""
|
||||
if config['ui']['color']:
|
||||
return _colordiff(a, b, highlight)
|
||||
return _colordiff(a, b)
|
||||
else:
|
||||
return str(a), str(b)
|
||||
|
||||
|
|
@ -648,6 +773,258 @@ def term_width():
|
|||
return width
|
||||
|
||||
|
||||
def split_into_lines(string, width_tuple):
|
||||
"""Splits string into a list of substrings at whitespace.
|
||||
|
||||
`width_tuple` is a 3-tuple of `(first_width, last_width, middle_width)`.
|
||||
The first substring has a length not longer than `first_width`, the last
|
||||
substring has a length not longer than `last_width`, and all other
|
||||
substrings have a length not longer than `middle_width`.
|
||||
`string` may contain ANSI codes at word borders.
|
||||
"""
|
||||
first_width, middle_width, last_width = width_tuple
|
||||
words = []
|
||||
if uncolorize(string) == string:
|
||||
# No colors in string
|
||||
words = string.split()
|
||||
else:
|
||||
# Use a regex to find escapes and the text within them.
|
||||
esc_text = re.compile(r"(?P<esc>\x1b\[[;\d]*[A-Za-z])"
|
||||
r"(?P<text>[^\x1b]+)", re.VERBOSE)
|
||||
for m in esc_text.finditer(string):
|
||||
# m contains two groups:
|
||||
# esc - intitial escape sequence
|
||||
# text - text, no escape sequence, may contain spaces
|
||||
raw_words = m.group("text").split()
|
||||
# Reconstruct colored words, without spaces.
|
||||
words += [m.group("esc") + raw_word
|
||||
+ RESET_COLOR for raw_word in raw_words]
|
||||
result = []
|
||||
next_substr = ""
|
||||
# Iterate over all words.
|
||||
for i in range(len(words)):
|
||||
if i == 0:
|
||||
pot_substr = words[i]
|
||||
else:
|
||||
pot_substr = " ".join([next_substr, words[i]])
|
||||
# Find out if the pot(ential)_substr fits into the next substring.
|
||||
fits_first = (
|
||||
len(result) == 0 and color_len(pot_substr) <= first_width
|
||||
)
|
||||
fits_middle = (
|
||||
len(result) != 0 and color_len(pot_substr) <= middle_width
|
||||
)
|
||||
if fits_first or fits_middle:
|
||||
next_substr = pot_substr
|
||||
else:
|
||||
result.append(next_substr)
|
||||
next_substr = words[i]
|
||||
# We finished constructing the substrings, but the last substring
|
||||
# has not yet been added to the result.
|
||||
result.append(next_substr)
|
||||
# Also, the length of the last substring was only checked against
|
||||
# `middle_width`. Append an empty substring as the new last substring if
|
||||
# the last substring is too long.
|
||||
if not color_len(next_substr) <= last_width:
|
||||
result.append('')
|
||||
return result
|
||||
|
||||
|
||||
def print_column_layout(
|
||||
indent_str, left, right, separator=" -> ", max_width=term_width()
|
||||
):
|
||||
"""Print left & right data, with separator inbetween
|
||||
'left' and 'right' have a structure of:
|
||||
{'prefix':u'','contents':u'','suffix':u'','width':0}
|
||||
In a column layout the printing will be:
|
||||
{indent_str}{lhs0}{separator}{rhs0}
|
||||
{lhs1 / padding }{rhs1}
|
||||
...
|
||||
The first line of each column (i.e. {lhs0} or {rhs0}) is:
|
||||
{prefix}{part of contents}{suffix}
|
||||
With subsequent lines (i.e. {lhs1}, {rhs1} onwards) being the
|
||||
rest of contents, wrapped if the width would be otherwise exceeded.
|
||||
"""
|
||||
if right["prefix"] + right["contents"] + right["suffix"] == '':
|
||||
# No right hand information, so we don't need a separator.
|
||||
separator = ""
|
||||
first_line_no_wrap = (
|
||||
indent_str
|
||||
+ left["prefix"]
|
||||
+ left["contents"]
|
||||
+ left["suffix"]
|
||||
+ separator
|
||||
+ right["prefix"]
|
||||
+ right["contents"]
|
||||
+ right["suffix"]
|
||||
)
|
||||
if color_len(first_line_no_wrap) < max_width:
|
||||
# Everything fits, print out line.
|
||||
print_(first_line_no_wrap)
|
||||
else:
|
||||
# Wrap into columns
|
||||
if "width" not in left or "width" not in right:
|
||||
# If widths have not been defined, set to share space.
|
||||
left["width"] = (max_width - len(indent_str)
|
||||
- color_len(separator)) // 2
|
||||
right["width"] = (max_width - len(indent_str)
|
||||
- color_len(separator)) // 2
|
||||
# On the first line, account for suffix as well as prefix
|
||||
left_width_tuple = (
|
||||
left["width"] - color_len(left["prefix"])
|
||||
- color_len(left["suffix"]),
|
||||
left["width"] - color_len(left["prefix"]),
|
||||
left["width"] - color_len(left["prefix"]),
|
||||
)
|
||||
|
||||
left_split = split_into_lines(left["contents"], left_width_tuple)
|
||||
right_width_tuple = (
|
||||
right["width"] - color_len(right["prefix"])
|
||||
- color_len(right["suffix"]),
|
||||
right["width"] - color_len(right["prefix"]),
|
||||
right["width"] - color_len(right["prefix"]),
|
||||
)
|
||||
|
||||
right_split = split_into_lines(right["contents"], right_width_tuple)
|
||||
max_line_count = max(len(left_split), len(right_split))
|
||||
|
||||
out = ""
|
||||
for i in range(max_line_count):
|
||||
# indentation
|
||||
out += indent_str
|
||||
|
||||
# Prefix or indent_str for line
|
||||
if i == 0:
|
||||
out += left["prefix"]
|
||||
else:
|
||||
out += indent(color_len(left["prefix"]))
|
||||
|
||||
# Line i of left hand side contents.
|
||||
if i < len(left_split):
|
||||
out += left_split[i]
|
||||
left_part_len = color_len(left_split[i])
|
||||
else:
|
||||
left_part_len = 0
|
||||
|
||||
# Padding until end of column.
|
||||
# Note: differs from original
|
||||
# column calcs in not -1 afterwards for space
|
||||
# in track number as that is included in 'prefix'
|
||||
padding = left["width"] - color_len(left["prefix"]) - left_part_len
|
||||
|
||||
# Remove some padding on the first line to display
|
||||
# length
|
||||
if i == 0:
|
||||
padding -= color_len(left["suffix"])
|
||||
|
||||
out += indent(padding)
|
||||
|
||||
if i == 0:
|
||||
out += left["suffix"]
|
||||
|
||||
# Separator between columns.
|
||||
if i == 0:
|
||||
out += separator
|
||||
else:
|
||||
out += indent(color_len(separator))
|
||||
|
||||
# Right prefix, contents, padding, suffix
|
||||
if i == 0:
|
||||
out += right["prefix"]
|
||||
else:
|
||||
out += indent(color_len(right["prefix"]))
|
||||
|
||||
# Line i of right hand side.
|
||||
if i < len(right_split):
|
||||
out += right_split[i]
|
||||
right_part_len = color_len(right_split[i])
|
||||
else:
|
||||
right_part_len = 0
|
||||
|
||||
# Padding until end of column
|
||||
padding = right["width"] - color_len(right["prefix"]) \
|
||||
- right_part_len
|
||||
# Remove some padding on the first line to display
|
||||
# length
|
||||
if i == 0:
|
||||
padding -= color_len(right["suffix"])
|
||||
out += indent(padding)
|
||||
# Length in first line
|
||||
if i == 0:
|
||||
out += right["suffix"]
|
||||
|
||||
# Linebreak, except in the last line.
|
||||
if i < max_line_count - 1:
|
||||
out += "\n"
|
||||
|
||||
# Constructed all of the columns, now print
|
||||
print_(out)
|
||||
|
||||
|
||||
def print_newline_layout(
|
||||
indent_str, left, right, separator=" -> ", max_width=term_width()
|
||||
):
|
||||
"""Prints using a newline separator between left & right if
|
||||
they go over their allocated widths. The datastructures are
|
||||
shared with the column layout. In contrast to the column layout,
|
||||
the prefix and suffix are printed at the beginning and end of
|
||||
the contents. If no wrapping is required (i.e. everything fits) the
|
||||
first line will look exactly the same as the column layout:
|
||||
{indent}{lhs0}{separator}{rhs0}
|
||||
However if this would go over the width given, the layout now becomes:
|
||||
{indent}{lhs0}
|
||||
{indent}{separator}{rhs0}
|
||||
If {lhs0} would go over the maximum width, the subsequent lines are
|
||||
indented a second time for ease of reading.
|
||||
"""
|
||||
if right["prefix"] + right["contents"] + right["suffix"] == '':
|
||||
# No right hand information, so we don't need a separator.
|
||||
separator = ""
|
||||
first_line_no_wrap = (
|
||||
indent_str
|
||||
+ left["prefix"]
|
||||
+ left["contents"]
|
||||
+ left["suffix"]
|
||||
+ separator
|
||||
+ right["prefix"]
|
||||
+ right["contents"]
|
||||
+ right["suffix"]
|
||||
)
|
||||
if color_len(first_line_no_wrap) < max_width:
|
||||
# Everything fits, print out line.
|
||||
print_(first_line_no_wrap)
|
||||
else:
|
||||
# Newline separation, with wrapping
|
||||
empty_space = max_width - len(indent_str)
|
||||
# On lower lines we will double the indent for clarity
|
||||
left_width_tuple = (
|
||||
empty_space,
|
||||
empty_space - len(indent_str),
|
||||
empty_space - len(indent_str),
|
||||
)
|
||||
left_str = left["prefix"] + left["contents"] + left["suffix"]
|
||||
left_split = split_into_lines(left_str, left_width_tuple)
|
||||
# Repeat calculations for rhs, including separator on first line
|
||||
right_width_tuple = (
|
||||
empty_space - color_len(separator),
|
||||
empty_space - len(indent_str),
|
||||
empty_space - len(indent_str),
|
||||
)
|
||||
right_str = right["prefix"] + right["contents"] + right["suffix"]
|
||||
right_split = split_into_lines(right_str, right_width_tuple)
|
||||
for i, line in enumerate(left_split):
|
||||
if i == 0:
|
||||
print_(indent_str + line)
|
||||
elif line != "":
|
||||
# Ignore empty lines
|
||||
print_(indent_str * 2 + line)
|
||||
for i, line in enumerate(right_split):
|
||||
if i == 0:
|
||||
print_(indent_str + separator + line)
|
||||
elif line != "":
|
||||
print_(indent_str * 2 + line)
|
||||
|
||||
|
||||
FLOAT_EPSILON = 0.01
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ from typing import Sequence
|
|||
|
||||
import beets
|
||||
from beets import ui
|
||||
from beets.ui import print_, input_, decargs, show_path_changes
|
||||
from beets.ui import print_, input_, decargs, show_path_changes, \
|
||||
print_newline_layout, print_column_layout
|
||||
from beets import autotag
|
||||
from beets.autotag import Recommendation
|
||||
from beets.autotag import hooks
|
||||
|
|
@ -160,7 +161,6 @@ default_commands.append(fields_cmd)
|
|||
# help: Print help text for commands
|
||||
|
||||
class HelpCommand(ui.Subcommand):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
'help', aliases=('?',),
|
||||
|
|
@ -243,18 +243,25 @@ def get_album_disambig_fields(info: hooks.AlbumInfo) -> Sequence[str]:
|
|||
return out
|
||||
|
||||
|
||||
def dist_colorize(string, dist):
|
||||
"""Formats a string as a colorized similarity string according to
|
||||
a distance.
|
||||
"""
|
||||
if dist <= config["match"]["strong_rec_thresh"].as_number():
|
||||
string = ui.colorize("text_success", string)
|
||||
elif dist <= config["match"]["medium_rec_thresh"].as_number():
|
||||
string = ui.colorize("text_warning", string)
|
||||
else:
|
||||
string = ui.colorize("text_error", string)
|
||||
return string
|
||||
|
||||
|
||||
def dist_string(dist):
|
||||
"""Formats a distance (a float) as a colorized similarity percentage
|
||||
string.
|
||||
"""
|
||||
out = '%.1f%%' % ((1 - dist) * 100)
|
||||
if dist <= config['match']['strong_rec_thresh'].as_number():
|
||||
out = ui.colorize('text_success', out)
|
||||
elif dist <= config['match']['medium_rec_thresh'].as_number():
|
||||
out = ui.colorize('text_warning', out)
|
||||
else:
|
||||
out = ui.colorize('text_error', out)
|
||||
return out
|
||||
string = "{:.1f}%".format(((1 - dist) * 100))
|
||||
return dist_colorize(string, dist)
|
||||
|
||||
|
||||
def penalty_string(distance, limit=None):
|
||||
|
|
@ -270,24 +277,172 @@ def penalty_string(distance, limit=None):
|
|||
if penalties:
|
||||
if limit and len(penalties) > limit:
|
||||
penalties = penalties[:limit] + ['...']
|
||||
return ui.colorize('text_warning', '(%s)' % ', '.join(penalties))
|
||||
# Prefix penalty string with U+2260: Not Equal To
|
||||
penalty_string = "\u2260 {}".format(", ".join(penalties))
|
||||
return ui.colorize("changed", penalty_string)
|
||||
|
||||
|
||||
def show_change(cur_artist, cur_album, match):
|
||||
"""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.
|
||||
class ChangeRepresentation(object):
|
||||
"""Keeps track of all information needed to generate a (colored) text
|
||||
representation of the changes that will be made if an album or singleton's
|
||||
tags are changed according to `match`, which must be an AlbumMatch or
|
||||
TrackMatch object, accordingly.
|
||||
"""
|
||||
def show_album(artist, album):
|
||||
if artist:
|
||||
album_description = f' {artist} - {album}'
|
||||
elif album:
|
||||
album_description = ' %s' % album
|
||||
else:
|
||||
album_description = ' (unknown album)'
|
||||
print_(album_description)
|
||||
|
||||
def format_index(track_info):
|
||||
cur_artist = None
|
||||
# cur_album set if album, cur_title set if singleton
|
||||
cur_album = None
|
||||
cur_title = None
|
||||
match = None
|
||||
indent_header = ""
|
||||
indent_detail = ""
|
||||
|
||||
def __init__(self):
|
||||
# Read match header indentation width from config.
|
||||
match_header_indent_width = config["ui"]["import"]["indentation"][
|
||||
"match_header"
|
||||
].as_number()
|
||||
self.indent_header = ui.indent(match_header_indent_width)
|
||||
|
||||
# Read match detail indentation width from config.
|
||||
match_detail_indent_width = config["ui"]["import"]["indentation"][
|
||||
"match_details"
|
||||
].as_number()
|
||||
self.indent_detail = ui.indent(match_detail_indent_width)
|
||||
|
||||
# Read match tracklist indentation width from config
|
||||
match_tracklist_indent_width = config["ui"]["import"]["indentation"][
|
||||
"match_tracklist"
|
||||
].as_number()
|
||||
self.indent_tracklist = ui.indent(match_tracklist_indent_width)
|
||||
self.layout = config["ui"]["import"]["layout"].as_choice(
|
||||
{
|
||||
"column": 0,
|
||||
"newline": 1,
|
||||
}
|
||||
)
|
||||
|
||||
def print_layout(self, indent, left, right, separator=" -> ",
|
||||
max_width=None):
|
||||
if not max_width:
|
||||
# If no max_width provided, use terminal width
|
||||
max_width = ui.term_width()
|
||||
if self.layout == 0:
|
||||
print_column_layout(indent, left, right, separator, max_width)
|
||||
else:
|
||||
print_newline_layout(indent, left, right, separator, max_width)
|
||||
|
||||
def show_match_header(self):
|
||||
"""Print out a 'header' identifying the suggested match (album name,
|
||||
artist name,...) and summarizing the changes that would be made should
|
||||
the user accept the match.
|
||||
"""
|
||||
# Print newline at beginning of change block.
|
||||
print_("")
|
||||
|
||||
# 'Match' line and similarity.
|
||||
print_(self.indent_header +
|
||||
f"Match ({dist_string(self.match.distance)}):")
|
||||
|
||||
if self.match.info.get("album"):
|
||||
# Matching an album - print that
|
||||
artist_album_str = f"{self.match.info.artist}" + \
|
||||
f" - {self.match.info.album}"
|
||||
else:
|
||||
# Matching a single track
|
||||
artist_album_str = f"{self.match.info.artist}" + \
|
||||
f" - {self.match.info.title}"
|
||||
print_(
|
||||
self.indent_header +
|
||||
dist_colorize(artist_album_str, self.match.distance)
|
||||
)
|
||||
|
||||
# Penalties.
|
||||
penalties = penalty_string(self.match.distance)
|
||||
if penalties:
|
||||
print_(self.indent_header + penalties)
|
||||
|
||||
# Disambiguation.
|
||||
disambig = disambig_string(self.match.info)
|
||||
if disambig:
|
||||
print_(self.indent_header + disambig)
|
||||
|
||||
# Data URL.
|
||||
if self.match.info.data_url:
|
||||
url = ui.colorize("text_faint", f"{self.match.info.data_url}")
|
||||
print_(self.indent_header + url)
|
||||
|
||||
def show_match_details(self):
|
||||
"""Print out the details of the match, including changes in album name
|
||||
and artist name.
|
||||
"""
|
||||
# Artist.
|
||||
artist_l, artist_r = self.cur_artist or "", self.match.info.artist
|
||||
if artist_r == VARIOUS_ARTISTS:
|
||||
# Hide artists for VA releases.
|
||||
artist_l, artist_r = "", ""
|
||||
if artist_l != artist_r:
|
||||
artist_l, artist_r = ui.colordiff(artist_l, artist_r)
|
||||
# Prefix with U+2260: Not Equal To
|
||||
left = {
|
||||
"prefix": ui.colorize("changed", "\u2260") + " Artist: ",
|
||||
"contents": artist_l,
|
||||
"suffix": "",
|
||||
}
|
||||
right = {"prefix": "", "contents": artist_r, "suffix": ""}
|
||||
self.print_layout(self.indent_detail, left, right)
|
||||
|
||||
else:
|
||||
print_(self.indent_detail + "*", "Artist:", artist_r)
|
||||
|
||||
if self.cur_album:
|
||||
# Album
|
||||
album_l, album_r = self.cur_album or "", self.match.info.album
|
||||
if (
|
||||
self.cur_album != self.match.info.album
|
||||
and self.match.info.album != VARIOUS_ARTISTS
|
||||
):
|
||||
album_l, album_r = ui.colordiff(album_l, album_r)
|
||||
# Prefix with U+2260: Not Equal To
|
||||
left = {
|
||||
"prefix": ui.colorize("changed", "\u2260") + " Album: ",
|
||||
"contents": album_l,
|
||||
"suffix": "",
|
||||
}
|
||||
right = {"prefix": "", "contents": album_r, "suffix": ""}
|
||||
self.print_layout(self.indent_detail, left, right)
|
||||
else:
|
||||
print_(self.indent_detail + "*", "Album:", album_r)
|
||||
elif self.cur_title:
|
||||
# Title - for singletons
|
||||
title_l, title_r = self.cur_title or "", self.match.info.title
|
||||
if self.cur_title != self.match.info.title:
|
||||
title_l, title_r = ui.colordiff(title_l, title_r)
|
||||
# Prefix with U+2260: Not Equal To
|
||||
left = {
|
||||
"prefix": ui.colorize("changed", "\u2260") + " Title: ",
|
||||
"contents": title_l,
|
||||
"suffix": "",
|
||||
}
|
||||
right = {"prefix": "", "contents": title_r, "suffix": ""}
|
||||
self.print_layout(self.indent_detail, left, right)
|
||||
else:
|
||||
print_(self.indent_detail + "*", "Title:", title_r)
|
||||
|
||||
def make_medium_info_line(self, track_info):
|
||||
"""Construct a line with the current medium's info."""
|
||||
media = self.match.info.media or "Media"
|
||||
# Build output string.
|
||||
if self.match.info.mediums > 1 and track_info.disctitle:
|
||||
return f"* {media} {track_info.medium}: {track_info.disctitle}"
|
||||
elif self.match.info.mediums > 1:
|
||||
return f"* {media} {track_info.medium}"
|
||||
elif track_info.disctitle:
|
||||
return f"* {media}: {track_info.disctitle}"
|
||||
else:
|
||||
return ""
|
||||
|
||||
def format_index(self, track_info):
|
||||
"""Return a string representing the track index of the given
|
||||
TrackInfo or Item object.
|
||||
"""
|
||||
|
|
@ -295,7 +450,7 @@ def show_change(cur_artist, cur_album, match):
|
|||
index = track_info.index
|
||||
medium_index = track_info.medium_index
|
||||
medium = track_info.medium
|
||||
mediums = match.info.mediums
|
||||
mediums = self.match.info.mediums
|
||||
else:
|
||||
index = medium_index = track_info.track
|
||||
medium = track_info.disc
|
||||
|
|
@ -309,205 +464,271 @@ def show_change(cur_artist, cur_album, match):
|
|||
else:
|
||||
return str(index)
|
||||
|
||||
# Identify the album in question.
|
||||
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 = '', ''
|
||||
|
||||
if config['artist_credit']:
|
||||
artist_r = match.info.artist_credit
|
||||
|
||||
artist_l, artist_r = ui.colordiff(artist_l, artist_r)
|
||||
album_l, album_r = ui.colordiff(album_l, album_r)
|
||||
|
||||
print_("Correcting tags from:")
|
||||
show_album(artist_l, album_l)
|
||||
print_("To:")
|
||||
show_album(artist_r, album_r)
|
||||
else:
|
||||
print_("Tagging:\n {0.artist} - {0.album}".format(match.info))
|
||||
|
||||
# Data URL.
|
||||
if match.info.data_url:
|
||||
print_('URL:\n %s' % match.info.data_url)
|
||||
|
||||
# Info line.
|
||||
info = []
|
||||
# Similarity.
|
||||
info.append('(Similarity: %s)' % dist_string(match.distance))
|
||||
# Penalties.
|
||||
penalties = penalty_string(match.distance)
|
||||
if penalties:
|
||||
info.append(penalties)
|
||||
# Disambiguation.
|
||||
disambig = disambig_string(match.info)
|
||||
if disambig:
|
||||
info.append(ui.colorize('text_highlight_minor', '(%s)' % disambig))
|
||||
print_(' '.join(info))
|
||||
|
||||
# Tracks.
|
||||
pairs = list(match.mapping.items())
|
||||
pairs.sort(key=lambda item_and_track_info: item_and_track_info[1].index)
|
||||
|
||||
# Build up LHS and RHS for track difference display. The `lines` list
|
||||
# contains ``(lhs, rhs, width)`` tuples where `width` is the length (in
|
||||
# characters) of the uncolorized LHS.
|
||||
lines = []
|
||||
medium = disctitle = None
|
||||
for item, track_info in pairs:
|
||||
|
||||
# Medium number and title.
|
||||
if medium != track_info.medium or disctitle != track_info.disctitle:
|
||||
media = match.info.media or 'Media'
|
||||
if match.info.mediums > 1 and track_info.disctitle:
|
||||
lhs = '{} {}: {}'.format(media, track_info.medium,
|
||||
track_info.disctitle)
|
||||
elif match.info.mediums > 1:
|
||||
lhs = f'{media} {track_info.medium}'
|
||||
elif track_info.disctitle:
|
||||
lhs = f'{media}: {track_info.disctitle}'
|
||||
def make_track_numbers(self, item, track_info):
|
||||
"""Format colored track indices."""
|
||||
cur_track = self.format_index(item)
|
||||
new_track = self.format_index(track_info)
|
||||
templ = "(#{})"
|
||||
changed = False
|
||||
# Choose color based on change.
|
||||
if cur_track != new_track:
|
||||
changed = True
|
||||
if item.track in (track_info.index, track_info.medium_index):
|
||||
highlight_color = "text_highlight_minor"
|
||||
else:
|
||||
lhs = None
|
||||
if lhs:
|
||||
lines.append((lhs, '', 0))
|
||||
medium, disctitle = track_info.medium, track_info.disctitle
|
||||
highlight_color = "text_highlight"
|
||||
else:
|
||||
highlight_color = "text_faint"
|
||||
|
||||
# Titles.
|
||||
cur_track = templ.format(cur_track)
|
||||
new_track = templ.format(new_track)
|
||||
lhs_track = ui.colorize(highlight_color, cur_track)
|
||||
rhs_track = ui.colorize(highlight_color, new_track)
|
||||
return lhs_track, rhs_track, changed
|
||||
|
||||
@staticmethod
|
||||
def make_track_titles(item, track_info):
|
||||
"""Format colored track titles."""
|
||||
new_title = track_info.title
|
||||
if not item.title.strip():
|
||||
# If there's no title, we use the filename.
|
||||
# If there's no title, we use the filename. Don't colordiff.
|
||||
cur_title = displayable_path(os.path.basename(item.path))
|
||||
lhs, rhs = cur_title, new_title
|
||||
return cur_title, new_title, True
|
||||
else:
|
||||
# If there is a title, highlight differences.
|
||||
cur_title = item.title.strip()
|
||||
lhs, rhs = ui.colordiff(cur_title, new_title)
|
||||
lhs_width = len(cur_title)
|
||||
cur_col, new_col = ui.colordiff(cur_title, new_title)
|
||||
return cur_col, new_col, cur_title != new_title
|
||||
|
||||
@staticmethod
|
||||
def make_track_lengths(item, track_info):
|
||||
"""Format colored track lengths."""
|
||||
changed = False
|
||||
if (
|
||||
item.length
|
||||
and track_info.length
|
||||
and abs(item.length - track_info.length)
|
||||
> config["ui"]["length_diff_thresh"].as_number()
|
||||
):
|
||||
highlight_color = "text_highlight"
|
||||
changed = True
|
||||
else:
|
||||
highlight_color = "text_highlight_minor"
|
||||
|
||||
# Handle nonetype lengths by setting to 0
|
||||
cur_length0 = item.length if item.length else 0
|
||||
new_length0 = track_info.length if track_info.length else 0
|
||||
# format into string
|
||||
cur_length = f"({ui.human_seconds_short(cur_length0)})"
|
||||
new_length = f"({ui.human_seconds_short(new_length0)})"
|
||||
# colorize
|
||||
lhs_length = ui.colorize(highlight_color, cur_length)
|
||||
rhs_length = ui.colorize(highlight_color, new_length)
|
||||
|
||||
return lhs_length, rhs_length, changed
|
||||
|
||||
def make_line(self, item, track_info):
|
||||
"""Extract changes from item -> new TrackInfo object, and colorize
|
||||
appropriately. Returns (lhs, rhs) for column printing.
|
||||
"""
|
||||
# Track titles.
|
||||
lhs_title, rhs_title, diff_title = \
|
||||
self.make_track_titles(item, track_info)
|
||||
# Track number change.
|
||||
cur_track, new_track = format_index(item), format_index(track_info)
|
||||
if cur_track != new_track:
|
||||
if item.track in (track_info.index, track_info.medium_index):
|
||||
color = 'text_highlight_minor'
|
||||
else:
|
||||
color = 'text_highlight'
|
||||
templ = ui.colorize(color, ' (#{0})')
|
||||
lhs += templ.format(cur_track)
|
||||
rhs += templ.format(new_track)
|
||||
lhs_width += len(cur_track) + 4
|
||||
|
||||
lhs_track, rhs_track, diff_track = \
|
||||
self.make_track_numbers(item, track_info)
|
||||
# Length change.
|
||||
if item.length and track_info.length and \
|
||||
abs(item.length - track_info.length) > \
|
||||
config['ui']['length_diff_thresh'].as_number():
|
||||
cur_length = ui.human_seconds_short(item.length)
|
||||
new_length = ui.human_seconds_short(track_info.length)
|
||||
templ = ui.colorize('text_highlight', ' ({0})')
|
||||
lhs += templ.format(cur_length)
|
||||
rhs += templ.format(new_length)
|
||||
lhs_width += len(cur_length) + 3
|
||||
lhs_length, rhs_length, diff_length = \
|
||||
self.make_track_lengths(item, track_info)
|
||||
|
||||
# Penalties.
|
||||
penalties = penalty_string(match.distance.tracks[track_info])
|
||||
if penalties:
|
||||
rhs += ' %s' % penalties
|
||||
changed = diff_title or diff_track or diff_length
|
||||
|
||||
if lhs != rhs:
|
||||
lines.append((' * %s' % lhs, rhs, lhs_width))
|
||||
elif config['import']['detail']:
|
||||
lines.append((' * %s' % lhs, '', lhs_width))
|
||||
# Construct lhs and rhs dicts.
|
||||
# Previously, we printed the penalties, however this is no longer
|
||||
# the case, thus the 'info' dictionary is unneeded.
|
||||
# penalties = penalty_string(self.match.distance.tracks[track_info])
|
||||
|
||||
# Print each track in two columns, or across two lines.
|
||||
col_width = (ui.term_width() - len(''.join([' * ', ' -> ']))) // 2
|
||||
if lines:
|
||||
max_width = max(w for _, _, w in lines)
|
||||
for lhs, rhs, lhs_width in lines:
|
||||
if not rhs:
|
||||
print_(lhs)
|
||||
elif max_width > col_width:
|
||||
print_(f'{lhs} ->\n {rhs}')
|
||||
else:
|
||||
pad = max_width - lhs_width
|
||||
print_('{}{} -> {}'.format(lhs, ' ' * pad, rhs))
|
||||
prefix = ui.colorize("changed", "\u2260 ") if changed else "* "
|
||||
lhs = {
|
||||
"prefix": prefix + lhs_track + " ",
|
||||
"contents": lhs_title,
|
||||
"suffix": " " + lhs_length,
|
||||
}
|
||||
rhs = {"prefix": "", "contents": "", "suffix": ""}
|
||||
if not changed:
|
||||
# Only return the left side, as nothing changed.
|
||||
return (lhs, rhs)
|
||||
else:
|
||||
# Construct a dictionary for the "changed to" side
|
||||
rhs = {
|
||||
"prefix": rhs_track + " ",
|
||||
"contents": rhs_title,
|
||||
"suffix": " " + rhs_length,
|
||||
}
|
||||
return (lhs, rhs)
|
||||
|
||||
# Missing and unmatched tracks.
|
||||
if match.extra_tracks:
|
||||
print_('Missing tracks ({}/{} - {:.1%}):'.format(
|
||||
len(match.extra_tracks),
|
||||
len(match.info.tracks),
|
||||
len(match.extra_tracks) / len(match.info.tracks)
|
||||
))
|
||||
pad_width = max(len(track_info.title) for track_info in
|
||||
match.extra_tracks)
|
||||
for track_info in match.extra_tracks:
|
||||
line = ' ! {0: <{width}} (#{1: >2})'.format(track_info.title,
|
||||
format_index(track_info),
|
||||
width=pad_width)
|
||||
if track_info.length:
|
||||
line += ' (%s)' % ui.human_seconds_short(track_info.length)
|
||||
print_(ui.colorize('text_warning', line))
|
||||
if match.extra_items:
|
||||
print_('Unmatched tracks ({}):'.format(len(match.extra_items)))
|
||||
pad_width = max(len(item.title) for item in match.extra_items)
|
||||
for item in match.extra_items:
|
||||
line = ' ! {0: <{width}} (#{1: >2})'.format(item.title,
|
||||
format_index(item),
|
||||
width=pad_width)
|
||||
if item.length:
|
||||
line += ' (%s)' % ui.human_seconds_short(item.length)
|
||||
print_(ui.colorize('text_warning', line))
|
||||
def print_tracklist(self, lines):
|
||||
"""Calculates column widths for tracks stored as line tuples:
|
||||
(left, right). Then prints each line of tracklist.
|
||||
"""
|
||||
if len(lines) == 0:
|
||||
# If no lines provided, e.g. details not required, do nothing.
|
||||
return
|
||||
|
||||
def get_width(side):
|
||||
"""Return the width of left or right in uncolorized characters."""
|
||||
try:
|
||||
return len(
|
||||
ui.uncolorize(
|
||||
" ".join([side["prefix"],
|
||||
side["contents"],
|
||||
side["suffix"]])
|
||||
)
|
||||
)
|
||||
except KeyError:
|
||||
# An empty dictionary -> Nothing to report
|
||||
return 0
|
||||
|
||||
# Check how to fit content into terminal window
|
||||
indent_width = len(self.indent_tracklist)
|
||||
terminal_width = ui.term_width()
|
||||
joiner_width = len("".join(["* ", " -> "]))
|
||||
col_width = (terminal_width - indent_width - joiner_width) // 2
|
||||
max_width_l = max(get_width(line_tuple[0]) for line_tuple in lines)
|
||||
max_width_r = max(get_width(line_tuple[1]) for line_tuple in lines)
|
||||
|
||||
if (
|
||||
(max_width_l <= col_width)
|
||||
and (max_width_r <= col_width)
|
||||
or (
|
||||
((max_width_l > col_width) or (max_width_r > col_width))
|
||||
and ((max_width_l + max_width_r) <= col_width * 2)
|
||||
)
|
||||
):
|
||||
# All content fits. Either both maximum widths are below column
|
||||
# widths, or one of the columns is larger than allowed but the
|
||||
# other is smaller than allowed.
|
||||
# In this case we can afford to shrink the columns to fit their
|
||||
# largest string
|
||||
col_width_l = max_width_l
|
||||
col_width_r = max_width_r
|
||||
else:
|
||||
# Not all content fits - stick with original half/half split
|
||||
col_width_l = col_width
|
||||
col_width_r = col_width
|
||||
|
||||
# Print out each line, using the calculated width from above.
|
||||
for left, right in lines:
|
||||
left["width"] = col_width_l
|
||||
right["width"] = col_width_r
|
||||
self.print_layout(self.indent_tracklist, left, right)
|
||||
|
||||
|
||||
class AlbumChange(ChangeRepresentation):
|
||||
"""Album change representation, setting cur_album"""
|
||||
|
||||
def __init__(self, cur_artist, cur_album, match):
|
||||
super(AlbumChange, self).__init__()
|
||||
self.cur_artist = cur_artist
|
||||
self.cur_album = cur_album
|
||||
self.match = match
|
||||
|
||||
def show_match_tracks(self):
|
||||
"""Print out the tracks of the match, summarizing changes the match
|
||||
suggests for them.
|
||||
"""
|
||||
# Tracks.
|
||||
# match is an AlbumMatch named tuple, mapping is a dict
|
||||
# Sort the pairs by the track_info index (at index 1 of the namedtuple)
|
||||
pairs = list(self.match.mapping.items())
|
||||
pairs.sort(
|
||||
key=lambda item_and_track_info: item_and_track_info[1].index)
|
||||
# Build up LHS and RHS for track difference display. The `lines` list
|
||||
# contains `(left, right)` tuples.
|
||||
lines = []
|
||||
medium = disctitle = None
|
||||
for item, track_info in pairs:
|
||||
# If the track is the first on a new medium, show medium
|
||||
# number and title.
|
||||
if medium != track_info.medium or \
|
||||
disctitle != track_info.disctitle:
|
||||
# Create header for new medium
|
||||
header = self.make_medium_info_line(track_info)
|
||||
if header != "":
|
||||
# Print tracks from previous medium
|
||||
self.print_tracklist(lines)
|
||||
lines = []
|
||||
print_(self.indent_detail + header)
|
||||
# Save new medium details for future comparison.
|
||||
medium, disctitle = track_info.medium, track_info.disctitle
|
||||
|
||||
if config["import"]["detail"]:
|
||||
# Construct the line tuple for the track.
|
||||
left, right = self.make_line(item, track_info)
|
||||
lines.append((left, right))
|
||||
self.print_tracklist(lines)
|
||||
|
||||
# Missing and unmatched tracks.
|
||||
if self.match.extra_tracks:
|
||||
print_(
|
||||
"Missing tracks ({0}/{1} - {2:.1%}):".format(
|
||||
len(self.match.extra_tracks),
|
||||
len(self.match.info.tracks),
|
||||
len(self.match.extra_tracks) / len(self.match.info.tracks),
|
||||
)
|
||||
)
|
||||
for track_info in self.match.extra_tracks:
|
||||
line = f" ! {track_info.title} (#{self.format_index(track_info)})"
|
||||
if track_info.length:
|
||||
line += f" ({ui.human_seconds_short(track_info.length)})"
|
||||
print_(ui.colorize("text_warning", line))
|
||||
if self.match.extra_items:
|
||||
print_(f"Unmatched tracks ({len(self.match.extra_items)}):")
|
||||
for item in self.match.extra_items:
|
||||
line = " ! {} (#{})".format(item.title, self.format_index(item))
|
||||
if item.length:
|
||||
line += " ({})".format(ui.human_seconds_short(item.length))
|
||||
print_(ui.colorize("text_warning", line))
|
||||
|
||||
|
||||
class TrackChange(ChangeRepresentation):
|
||||
"""Track change representation, comparing item with match."""
|
||||
|
||||
def __init__(self, cur_artist, cur_title, match):
|
||||
super(TrackChange, self).__init__()
|
||||
self.cur_artist = cur_artist
|
||||
self.cur_title = cur_title
|
||||
self.match = match
|
||||
|
||||
|
||||
def show_change(cur_artist, cur_album, match):
|
||||
"""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.
|
||||
"""
|
||||
change = AlbumChange(cur_artist=cur_artist, cur_album=cur_album,
|
||||
match=match)
|
||||
|
||||
# Print the match header.
|
||||
change.show_match_header()
|
||||
|
||||
# Print the match details.
|
||||
change.show_match_details()
|
||||
|
||||
# Print the match tracks.
|
||||
change.show_match_tracks()
|
||||
|
||||
|
||||
def show_item_change(item, match):
|
||||
"""Print out the change that would occur by tagging `item` with the
|
||||
metadata from `match`, a TrackMatch object.
|
||||
"""
|
||||
cur_artist, new_artist = item.artist, match.info.artist
|
||||
cur_title, new_title = item.title, match.info.title
|
||||
cur_album = item.album if item.album else ""
|
||||
new_album = match.info.album if match.info.album else ""
|
||||
|
||||
if (cur_artist != new_artist or cur_title != new_title
|
||||
or cur_album != new_album):
|
||||
cur_artist, new_artist = ui.colordiff(cur_artist, new_artist)
|
||||
cur_title, new_title = ui.colordiff(cur_title, new_title)
|
||||
cur_album, new_album = ui.colordiff(cur_album, new_album)
|
||||
|
||||
print_("Correcting track tags from:")
|
||||
print_(f" {cur_artist} - {cur_title}")
|
||||
if cur_album:
|
||||
print_(f" Album: {cur_album}")
|
||||
print_("To:")
|
||||
print_(f" {new_artist} - {new_title}")
|
||||
if new_album:
|
||||
print_(f" Album: {new_album}")
|
||||
|
||||
else:
|
||||
print_(f"Tagging track: {cur_artist} - {cur_title}")
|
||||
if cur_album:
|
||||
print_(f" Album: {new_album}")
|
||||
|
||||
# Data URL.
|
||||
if match.info.data_url:
|
||||
print_('URL:\n %s' % match.info.data_url)
|
||||
|
||||
# Info line.
|
||||
info = []
|
||||
# Similarity.
|
||||
info.append('(Similarity: %s)' % dist_string(match.distance))
|
||||
# Penalties.
|
||||
penalties = penalty_string(match.distance)
|
||||
if penalties:
|
||||
info.append(penalties)
|
||||
# Disambiguation.
|
||||
disambig = disambig_string(match.info)
|
||||
if disambig:
|
||||
info.append(ui.colorize('text_highlight_minor', '(%s)' % disambig))
|
||||
print_(' '.join(info))
|
||||
change = TrackChange(cur_artist=item.artist, cur_title=item.title,
|
||||
match=match)
|
||||
# Print the match header.
|
||||
change.show_match_header()
|
||||
# Print the match details.
|
||||
change.show_match_details()
|
||||
|
||||
|
||||
def summarize_items(items, singleton):
|
||||
|
|
@ -640,36 +861,40 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None,
|
|||
|
||||
if not bypass_candidates:
|
||||
# Display list of candidates.
|
||||
print_("")
|
||||
print_('Finding tags for {} "{} - {}".'.format(
|
||||
'track' if singleton else 'album',
|
||||
item.artist if singleton else cur_artist,
|
||||
item.title if singleton else cur_album,
|
||||
))
|
||||
|
||||
print_('Candidates:')
|
||||
print_(ui.indent(2) + 'Candidates:')
|
||||
for i, match in enumerate(candidates):
|
||||
# Index, metadata, and distance.
|
||||
line = [
|
||||
'{}.'.format(i + 1),
|
||||
'{} - {}'.format(
|
||||
match.info.artist,
|
||||
match.info.title if singleton else match.info.album,
|
||||
),
|
||||
'({})'.format(dist_string(match.distance)),
|
||||
]
|
||||
index0 = "{0}.".format(i + 1)
|
||||
index = dist_colorize(index0, match.distance)
|
||||
dist = "({:.1f}%)".format((1 - match.distance) * 100)
|
||||
distance = dist_colorize(dist, match.distance)
|
||||
metadata = "{0} - {1}".format(
|
||||
match.info.artist,
|
||||
match.info.title if singleton else match.info.album,
|
||||
)
|
||||
if i == 0:
|
||||
metadata = dist_colorize(metadata, match.distance)
|
||||
else:
|
||||
metadata = ui.colorize("text_highlight_minor", metadata)
|
||||
line1 = [index, distance, metadata]
|
||||
print_(ui.indent(2) + " ".join(line1))
|
||||
|
||||
# Penalties.
|
||||
penalties = penalty_string(match.distance, 3)
|
||||
if penalties:
|
||||
line.append(penalties)
|
||||
print_(ui.indent(13) + penalties)
|
||||
|
||||
# Disambiguation
|
||||
disambig = disambig_string(match.info)
|
||||
if disambig:
|
||||
line.append(ui.colorize('text_highlight_minor',
|
||||
'(%s)' % disambig))
|
||||
|
||||
print_(' '.join(line))
|
||||
print_(ui.indent(13) + disambig)
|
||||
|
||||
# Ask the user for a choice.
|
||||
sel = ui.input_options(choice_opts,
|
||||
|
|
@ -769,8 +994,12 @@ class TerminalImportSession(importer.ImportSession):
|
|||
"""
|
||||
# Show what we're tagging.
|
||||
print_()
|
||||
print_(displayable_path(task.paths, '\n') +
|
||||
' ({} items)'.format(len(task.items)))
|
||||
|
||||
path_str0 = displayable_path(task.paths, '\n')
|
||||
path_str = ui.colorize('import_path', path_str0)
|
||||
items_str0 = '({} items)'.format(len(task.items))
|
||||
items_str = ui.colorize('import_path_items', items_str0)
|
||||
print_(' '.join([path_str, items_str]))
|
||||
|
||||
# Let plugins display info or prompt the user before we go through the
|
||||
# process of selecting candidate.
|
||||
|
|
|
|||
|
|
@ -1184,59 +1184,114 @@ class ShowChangeTest(_common.TestCase):
|
|||
]
|
||||
)
|
||||
|
||||
def _show_change(self, items=None, info=None,
|
||||
def _show_change(self, items=None, info=None, color=False,
|
||||
cur_artist='the artist', cur_album='the album',
|
||||
dist=0.1):
|
||||
"""Return an unicode string representing the changes"""
|
||||
items = items or self.items
|
||||
info = info or self.info
|
||||
mapping = dict(zip(items, info.tracks))
|
||||
config['ui']['color'] = False
|
||||
album_dist = distance(items, info, mapping)
|
||||
album_dist._penalties = {'album': [dist]}
|
||||
config['ui']['color'] = color
|
||||
config['import']['detail'] = True
|
||||
change_dist = distance(items, info, mapping)
|
||||
change_dist._penalties = {'album': [dist], 'artist': [dist]}
|
||||
commands.show_change(
|
||||
cur_artist,
|
||||
cur_album,
|
||||
autotag.AlbumMatch(album_dist, info, mapping, set(), set()),
|
||||
autotag.AlbumMatch(change_dist, info, mapping, set(), set()),
|
||||
)
|
||||
return self.io.getoutput().lower()
|
||||
|
||||
def test_null_change(self):
|
||||
msg = self._show_change()
|
||||
self.assertTrue('similarity: 90' in msg)
|
||||
self.assertTrue('tagging:' in msg)
|
||||
self.assertTrue('match (90.0%)' in msg)
|
||||
self.assertTrue('album, artist' in msg)
|
||||
|
||||
def test_album_data_change(self):
|
||||
msg = self._show_change(cur_artist='another artist',
|
||||
cur_album='another album')
|
||||
self.assertTrue('correcting tags from:' in msg)
|
||||
self.assertTrue('another artist -> the artist' in msg)
|
||||
self.assertTrue('another album -> the album' in msg)
|
||||
|
||||
def test_item_data_change(self):
|
||||
self.items[0].title = 'different'
|
||||
msg = self._show_change()
|
||||
self.assertTrue('different -> the title' in msg)
|
||||
self.assertTrue('different' in msg and 'the title' in msg)
|
||||
|
||||
def test_item_data_change_with_unicode(self):
|
||||
self.items[0].title = 'caf\xe9'
|
||||
msg = self._show_change()
|
||||
self.assertTrue('caf\xe9 -> the title' in msg)
|
||||
self.assertTrue(u'caf\xe9' in msg and 'the title' in msg)
|
||||
|
||||
def test_album_data_change_with_unicode(self):
|
||||
msg = self._show_change(cur_artist='caf\xe9',
|
||||
cur_album='another album')
|
||||
self.assertTrue('correcting tags from:' in msg)
|
||||
msg = self._show_change(cur_artist=u'caf\xe9',
|
||||
cur_album=u'another album')
|
||||
self.assertTrue(u'caf\xe9' in msg and 'the artist' in msg)
|
||||
|
||||
def test_item_data_change_title_missing(self):
|
||||
self.items[0].title = ''
|
||||
msg = re.sub(r' +', ' ', self._show_change())
|
||||
self.assertTrue('file.mp3 -> the title' in msg)
|
||||
self.assertTrue(u'file.mp3' in msg and 'the title' in msg)
|
||||
|
||||
def test_item_data_change_title_missing_with_unicode_filename(self):
|
||||
self.items[0].title = ''
|
||||
self.items[0].path = '/path/to/caf\xe9.mp3'.encode()
|
||||
msg = re.sub(r' +', ' ', self._show_change())
|
||||
self.assertTrue('caf\xe9.mp3 -> the title' in msg or
|
||||
'caf.mp3 ->' in msg)
|
||||
self.assertTrue(u'caf\xe9.mp3' in msg or
|
||||
u'caf.mp3' in msg)
|
||||
|
||||
def test_split_into_lines(self):
|
||||
# Test uncolored text
|
||||
txt = ui.split_into_lines("test test test", [5, 5, 5])
|
||||
self.assertEqual(txt, ["test", "test", "test"])
|
||||
# Test multiple colored texts
|
||||
colored_text = "\x1b[31mtest \x1b[39;49;00m" * 3
|
||||
split_txt = ["\x1b[31mtest\x1b[39;49;00m",
|
||||
"\x1b[31mtest\x1b[39;49;00m",
|
||||
"\x1b[31mtest\x1b[39;49;00m"]
|
||||
txt = ui.split_into_lines(colored_text, [5, 5, 5])
|
||||
self.assertEqual(txt, split_txt)
|
||||
# Test single color, multi space text
|
||||
colored_text = "\x1b[31m test test test \x1b[39;49;00m"
|
||||
txt = ui.split_into_lines(colored_text, [5, 5, 5])
|
||||
self.assertEqual(txt, split_txt)
|
||||
|
||||
def test_album_data_change_wrap_newline(self):
|
||||
# Patch ui.term_width to force wrapping
|
||||
with patch('beets.ui.commands.ui.term_width', return_value=30):
|
||||
# Test newline layout
|
||||
config['ui']['import']['layout'] = u'newline'
|
||||
long_name = u'another artist with a' + (u' very' * 10) + \
|
||||
u' long name'
|
||||
msg = self._show_change(cur_artist=long_name,
|
||||
cur_album='another album')
|
||||
# _common.log.info("Message:{}".format(msg))
|
||||
self.assertTrue('artist: another artist' in msg)
|
||||
self.assertTrue(' -> the artist' in msg)
|
||||
self.assertFalse('another album -> the album' in msg)
|
||||
|
||||
def test_item_data_change_wrap_column(self):
|
||||
# Patch ui.term_width to force wrapping
|
||||
with patch('beets.ui.commands.ui.term_width', return_value=54):
|
||||
# Test Column layout
|
||||
config['ui']['import']['layout'] = u'column'
|
||||
long_title = u'a track with a' + (u' very' * 10) + \
|
||||
u' long name'
|
||||
self.items[0].title = long_title
|
||||
msg = self._show_change()
|
||||
self.assertTrue('(#1) a track (1:00) -> (#1) the title (0:00)'
|
||||
in msg)
|
||||
|
||||
def test_item_data_change_wrap_newline(self):
|
||||
# Patch ui.term_width to force wrapping
|
||||
with patch('beets.ui.commands.ui.term_width', return_value=30):
|
||||
config['ui']['import']['layout'] = u'newline'
|
||||
long_title = u'a track with a' + (u' very' * 10) + \
|
||||
u' long name'
|
||||
self.items[0].title = long_title
|
||||
msg = self._show_change()
|
||||
self.assertTrue('(#1) a track with' in msg)
|
||||
self.assertTrue(' -> (#1) the title (0:00)' in msg)
|
||||
|
||||
|
||||
@patch('beets.library.Item.try_filesize', Mock(return_value=987))
|
||||
|
|
|
|||
Loading…
Reference in a new issue