Merge remote-tracking branch 'upstream/master' into spotify_timeout

This commit is contained in:
Alok Saboo 2023-10-15 09:36:54 -04:00
commit 66bf0023ea
12 changed files with 1502 additions and 365 deletions

View file

@ -97,13 +97,34 @@ ui:
length_diff_thresh: 10.0 length_diff_thresh: 10.0
color: yes color: yes
colors: colors:
text_success: green text_success: ['bold', 'green']
text_warning: yellow text_warning: ['bold', 'yellow']
text_error: red text_error: ['bold', 'red']
text_highlight: red text_highlight: ['bold', 'red']
text_highlight_minor: lightgray text_highlight_minor: ['white']
action_default: turquoise action_default: ['bold', 'cyan']
action: blue action: ['bold', 'cyan']
# New Colors
text: ['normal']
text_faint: ['faint']
import_path: ['bold', '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: ['white']
import:
indentation:
match_header: 2
match_details: 2
match_tracklist: 5
layout: column
format_item: $artist - $album - $title format_item: $artist - $album - $title
format_album: $albumartist - $album format_album: $albumartist - $album
@ -176,3 +197,5 @@ match:
ignore_video_tracks: yes ignore_video_tracks: yes
track_length_grace: 10 track_length_grace: 10
track_length_max: 30 track_length_max: 30
album_disambig_fields: data_source media year country label catalognum albumdisambig
singleton_disambig_fields: data_source index track_alt album

View file

@ -184,6 +184,13 @@ def should_move(move_opt=None):
# Input prompts. # Input prompts.
def indent(count):
"""Returns a string with `count` many spaces.
"""
return " " * count
def input_(prompt=None): def input_(prompt=None):
"""Like `input`, but decodes the result to a Unicode string. """Like `input`, but decodes the result to a Unicode string.
Raises a UserError if stdin is not available. The prompt is sent to 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) show_letter)
# Insert the highlighted letter back into the word. # Insert the highlighted letter back into the word.
descr_color = "action_default" if is_default else "action_description"
capitalized.append( 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()) 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] prompt_part_lengths += [len(s) for s in options]
# Wrap the query text. # Wrap the query text.
prompt = '' # Start prompt with U+279C: Heavy Round-Tipped Rightwards Arrow
prompt = colorize("action", "\u279C ")
line_length = 0 line_length = 0
for i, (part, length) in enumerate(zip(prompt_parts, for i, (part, length) in enumerate(zip(prompt_parts,
prompt_part_lengths)): prompt_part_lengths)):
# Add punctuation. # Add punctuation.
if i == len(prompt_parts) - 1: if i == len(prompt_parts) - 1:
part += '?' part += colorize("action_description", "?")
else: else:
part += ',' part += colorize("action_description", ",")
length += 1 length += 1
# Choose either the current line or the beginning of the next. # 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 """Prompts the user for a "yes" or "no" response. The default is
"yes" unless `require` is `True`, in which case there is no default. "yes" unless `require` is `True`, in which case there is no default.
""" """
sel = input_options( # Start prompt with U+279C: Heavy Round-Tipped Rightwards Arrow
('y', 'n'), require, prompt, 'Enter Y or N:' 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): def input_select_objects(prompt, objs, rep, prompt_all=None):
@ -465,51 +478,102 @@ def human_seconds_short(interval):
# https://bitbucket.org/birkenfeld/pygments-main/src/default/pygments/console.py # https://bitbucket.org/birkenfeld/pygments-main/src/default/pygments/console.py
# (pygments is by Tim Hatch, Armin Ronacher, et al.) # (pygments is by Tim Hatch, Armin Ronacher, et al.)
COLOR_ESCAPE = "\x1b[" COLOR_ESCAPE = "\x1b["
DARK_COLORS = { LEGACY_COLORS = {
"black": 0, "black": ["black"],
"darkred": 1, "darkred": ["red"],
"darkgreen": 2, "darkgreen": ["green"],
"brown": 3, "brown": ["yellow"],
"darkyellow": 3, "darkyellow": ["yellow"],
"darkblue": 4, "darkblue": ["blue"],
"purple": 5, "purple": ["magenta"],
"darkmagenta": 5, "darkmagenta": ["magenta"],
"teal": 6, "teal": ["cyan"],
"darkcyan": 6, "darkcyan": ["cyan"],
"lightgray": 7 "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 = { # All ANSI Colors.
"darkgray": 0, ANSI_CODES = {
"red": 1, # Styles.
"green": 2, "normal": 0,
"yellow": 3, "bold": 1,
"blue": 4, "faint": 2,
"fuchsia": 5, # "italic": 3,
"magenta": 5, "underline": 4,
"turquoise": 6, # "blink_slow": 5,
"cyan": 6, # "blink_rapid": 6,
"white": 7 "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" RESET_COLOR = COLOR_ESCAPE + "39;49;00m"
# These abstract COLOR_NAMES are lazily mapped on to the actual color in COLORS # 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 # as they are defined in the configuration files, see function: colorize
COLOR_NAMES = ['text_success', 'text_warning', 'text_error', 'text_highlight', COLOR_NAMES = [
'text_highlight_minor', 'action_default', 'action'] "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 COLORS = None
def _colorize(color, text): def _colorize(color, text):
"""Returns a string that prints the given text in the given color """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 a terminal that is ANSI color-aware. The color must be a list of strings
in DARK_COLORS or LIGHT_COLORS. from ANSI_CODES.
""" """
if color in DARK_COLORS: # Construct escape sequence to be put before the text by iterating
escape = COLOR_ESCAPE + "%im" % (DARK_COLORS[color] + 30) # over all "ANSI codes" in `color`.
elif color in LIGHT_COLORS: escape = ""
escape = COLOR_ESCAPE + "%i;01m" % (LIGHT_COLORS[color] + 30) for code in color:
else: escape = escape + COLOR_ESCAPE + "%im" % ANSI_CODES[code]
raise ValueError('no such color %s', color)
return escape + text + RESET_COLOR return escape + text + RESET_COLOR
@ -517,45 +581,128 @@ def colorize(color_name, text):
"""Colorize text if colored output is enabled. (Like _colorize but """Colorize text if colored output is enabled. (Like _colorize but
conditional.) conditional.)
""" """
if not config['ui']['color'] or 'NO_COLOR' in os.environ.keys(): if config["ui"]["color"]:
return text
global COLORS global COLORS
if not COLORS: if not COLORS:
COLORS = {name: # Read all color configurations and set global variable COLORS.
config['ui']['colors'][name].as_str() COLORS = dict()
for name in COLOR_NAMES} 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(str)
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') # In case a 3rd party plugin is still passing the actual color ('red')
# instead of the abstract color name ('text_error') # instead of the abstract color name ('text_error')
color = COLORS.get(color_name) color = COLORS.get(color_name)
if not color: if not color:
log.debug('Invalid color_name: {0}', color_name) log.debug("Invalid color_name: {0}", color_name)
color = color_name color = color_name
return _colorize(color, text) return _colorize(color, text)
else:
return text
def _colordiff(a, b, highlight='text_highlight', def uncolorize(colored_text):
minor_highlight='text_highlight_minor'): """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 color_split(colored_text, index):
ansi_code_regex = re.compile(r"(\x1b\[[;\d]*[A-Za-z])", re.VERBOSE)
length = 0
pre_split = ""
post_split = ""
found_color_code = None
found_split = False
for part in ansi_code_regex.split(colored_text):
# Count how many real letters we have passed
length += color_len(part)
if found_split:
post_split += part
else:
if ansi_code_regex.match(part):
# This is a color code
if part == RESET_COLOR:
found_color_code = None
else:
found_color_code = part
pre_split += part
else:
if index < length:
# Found part with our split in.
split_index = index - (length - color_len(part))
found_split = True
if found_color_code:
pre_split += part[:split_index] + RESET_COLOR
post_split += found_color_code + part[split_index:]
else:
pre_split += part[:split_index]
post_split += part[split_index:]
else:
# Not found, add this part to the pre split
pre_split += part
return pre_split, post_split
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 """Given two values, return the same pair of strings except with
their differences highlighted in the specified color. Strings are their differences highlighted in the specified color. Strings are
highlighted intelligently to show differences; other values are highlighted intelligently to show differences; other values are
stringified and highlighted in their entirety. stringified and highlighted in their entirety.
""" """
if not isinstance(a, str) \ # First, convert paths to readable format
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)
if isinstance(a, bytes) or isinstance(b, bytes): if isinstance(a, bytes) or isinstance(b, bytes):
# A path field. # A path field.
a = util.displayable_path(a) a = util.displayable_path(a)
b = util.displayable_path(b) 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 = [] a_out = []
b_out = [] b_out = []
@ -567,31 +714,36 @@ def _colordiff(a, b, highlight='text_highlight',
b_out.append(b[b_start:b_end]) b_out.append(b[b_start:b_end])
elif op == 'insert': elif op == 'insert':
# Right only. # Right only.
b_out.append(colorize(highlight, b[b_start:b_end])) b_out.append(colorize("text_diff_added",
b[b_start:b_end]))
elif op == 'delete': elif op == 'delete':
# Left only. # Left only.
a_out.append(colorize(highlight, a[a_start:a_end])) a_out.append(colorize("text_diff_removed",
a[a_start:a_end]))
elif op == 'replace': elif op == 'replace':
# Right and left differ. Colorise with second highlight if # Right and left differ. Colorise with second highlight if
# it's just a case change. # it's just a case change.
if a[a_start:a_end].lower() != b[b_start:b_end].lower(): 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: else:
color = minor_highlight a_color = b_color = "text_highlight_minor"
a_out.append(colorize(color, a[a_start:a_end])) a_out.append(colorize(a_color,
b_out.append(colorize(color, b[b_start:b_end])) a[a_start:a_end]))
b_out.append(colorize(b_color,
b[b_start:b_end]))
else: else:
assert False assert False
return ''.join(a_out), ''.join(b_out) 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. """Colorize differences between two values if color is enabled.
(Like _colordiff but conditional.) (Like _colordiff but conditional.)
""" """
if config['ui']['color']: if config['ui']['color']:
return _colordiff(a, b, highlight) return _colordiff(a, b)
else: else:
return str(a), str(b) return str(a), str(b)
@ -648,6 +800,335 @@ def term_width():
return 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 = []
esc_text = re.compile(r"""(?P<pretext>[^\x1b]*)
(?P<esc>(?:\x1b\[[;\d]*[A-Za-z])+)
(?P<text>[^\x1b]+)(?P<reset>\x1b\[39;49;00m)
(?P<posttext>[^\x1b]*)""",
re.VERBOSE)
if uncolorize(string) == string:
# No colors in string
words = string.split()
else:
# Use a regex to find escapes and the text within them.
for m in esc_text.finditer(string):
# m contains four groups:
# pretext - any text before escape sequence
# esc - intitial escape sequence
# text - text, no escape sequence, may contain spaces
# reset - ASCII colour reset
space_before_text = False
if m.group("pretext") != "":
# Some pretext found, let's handle it
# Add any words in the pretext
words += m.group("pretext").split()
if m.group("pretext")[-1] == " ":
# Pretext ended on a space
space_before_text = True
else:
# Pretext ended mid-word, ensure next word
pass
else:
# pretext empty, treat as if there is a space before
space_before_text = True
if m.group("text")[0] == " ":
# First character of the text is a space
space_before_text = True
# Now, handle the words in the main text:
raw_words = m.group("text").split()
if space_before_text:
# Colorize each word with pre/post escapes
# Reconstruct colored words
words += [m.group("esc") + raw_word
+ RESET_COLOR for raw_word in raw_words]
else:
# Pretext stops mid-word
if m.group("esc") != RESET_COLOR:
# Add the rest of the current word, with a reset after it
words[-1] += m.group("esc") + raw_words[0] + RESET_COLOR
# Add the subsequent colored words:
words += [m.group("esc") + raw_word
+ RESET_COLOR for raw_word in raw_words[1:]]
else:
# Caught a mid-word escape sequence
words[-1] += raw_words[0]
words += raw_words[1:]
if (m.group("text")[-1] != " " and m.group("posttext") != ""
and m.group("posttext")[0] != " "):
# reset falls mid-word
post_text = m.group("posttext").split()
words[-1] += post_text[0]
words += post_text[1:]
else:
# Add any words after escape sequence
words += m.group("posttext").split()
result = []
next_substr = ""
# Iterate over all words.
previous_fit = False
for i in range(len(words)):
if i == 0:
pot_substr = words[i]
else:
# (optimistically) add the next word to check the fit
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:
# Fitted(!) let's try and add another word before appending
next_substr = pot_substr
previous_fit = True
elif not fits_first and not fits_middle and previous_fit:
# Extra word didn't fit, append what we have
result.append(next_substr)
next_substr = words[i]
previous_fit = color_len(next_substr) <= middle_width
else:
# Didn't fit anywhere
if uncolorize(pot_substr) == pot_substr:
# Simple uncolored string, append a cropped word
if len(result) == 0:
# Crop word by the first_width for the first line
result.append(pot_substr[:first_width])
# add rest of word to next line
next_substr = pot_substr[first_width:]
else:
result.append(pot_substr[:middle_width])
next_substr = pot_substr[middle_width:]
else:
# Colored strings
if len(result) == 0:
this_line, next_line = color_split(pot_substr, first_width)
result.append(this_line)
next_substr = next_line
else:
this_line, next_line = color_split(pot_substr,
middle_width)
result.append(this_line)
next_substr = next_line
previous_fit = color_len(next_substr) <= middle_width
# 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 FLOAT_EPSILON = 0.01

View file

@ -22,10 +22,12 @@ import re
from platform import python_version from platform import python_version
from collections import namedtuple, Counter from collections import namedtuple, Counter
from itertools import chain from itertools import chain
from typing import Sequence
import beets import beets
from beets import ui 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 import autotag
from beets.autotag import Recommendation from beets.autotag import Recommendation
from beets.autotag import hooks from beets.autotag import hooks
@ -159,7 +161,6 @@ default_commands.append(fields_cmd)
# help: Print help text for commands # help: Print help text for commands
class HelpCommand(ui.Subcommand): class HelpCommand(ui.Subcommand):
def __init__(self): def __init__(self):
super().__init__( super().__init__(
'help', aliases=('?',), 'help', aliases=('?',),
@ -189,57 +190,78 @@ def disambig_string(info):
provides context that helps disambiguate similar-looking albums and provides context that helps disambiguate similar-looking albums and
tracks. tracks.
""" """
disambig = []
if info.data_source and info.data_source != 'MusicBrainz':
disambig.append(info.data_source)
if isinstance(info, hooks.AlbumInfo): if isinstance(info, hooks.AlbumInfo):
if info.media: disambig = get_album_disambig_fields(info)
if info.mediums and info.mediums > 1: elif isinstance(info, hooks.TrackInfo):
disambig.append('{}x{}'.format( disambig = get_singleton_disambig_fields(info)
info.mediums, info.media
))
else: else:
disambig.append(info.media) return ''
if info.year:
disambig.append(str(info.year))
if info.country:
disambig.append(info.country)
if info.label:
disambig.append(info.label)
if info.catalognum:
disambig.append(info.catalognum)
if info.albumdisambig:
disambig.append(info.albumdisambig)
# Let the user differentiate between pseudo and actual releases.
if info.albumstatus == 'Pseudo-Release':
disambig.append(info.albumstatus)
if isinstance(info, hooks.TrackInfo):
if info.index:
disambig.append("Index {}".format(str(info.index)))
if info.track_alt:
disambig.append("Track {}".format(info.track_alt))
if (config['import']['singleton_album_disambig'].get()
and info.get('album')):
disambig.append("[{}]".format(info.album))
if disambig:
return ', '.join(disambig) return ', '.join(disambig)
def get_singleton_disambig_fields(info: hooks.TrackInfo) -> Sequence[str]:
out = []
chosen_fields = config['match']['singleton_disambig_fields'].as_str_seq()
calculated_values = {
'index': "Index {}".format(str(info.index)),
'track_alt': "Track {}".format(info.track_alt),
'album': "[{}]".format(info.album) if
(config['import']['singleton_album_disambig'].get() and
info.get('album')) else '',
}
for field in chosen_fields:
if field in calculated_values:
out.append(str(calculated_values[field]))
else:
try:
out.append(str(info[field]))
except (AttributeError, KeyError):
print(f"Disambiguation string key {field} does not exist.")
return out
def get_album_disambig_fields(info: hooks.AlbumInfo) -> Sequence[str]:
out = []
chosen_fields = config['match']['album_disambig_fields'].as_str_seq()
calculated_values = {
'media': '{}x{}'.format(info.mediums, info.media) if
(info.mediums and info.mediums > 1) else info.media,
}
for field in chosen_fields:
if field in calculated_values:
out.append(str(calculated_values[field]))
else:
try:
out.append(str(info[field]))
except (AttributeError, KeyError):
print(f"Disambiguation string key {field} does not exist.")
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): def dist_string(dist):
"""Formats a distance (a float) as a colorized similarity percentage """Formats a distance (a float) as a colorized similarity percentage
string. string.
""" """
out = '%.1f%%' % ((1 - dist) * 100) string = "{:.1f}%".format(((1 - dist) * 100))
if dist <= config['match']['strong_rec_thresh'].as_number(): return dist_colorize(string, dist)
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
def penalty_string(distance, limit=None): def penalty_string(distance, limit=None):
@ -255,24 +277,172 @@ def penalty_string(distance, limit=None):
if penalties: if penalties:
if limit and len(penalties) > limit: if limit and len(penalties) > limit:
penalties = 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): class ChangeRepresentation(object):
"""Print out a representation of the changes that will be made if an """Keeps track of all information needed to generate a (colored) text
album's tags are changed according to `match`, which must be an AlbumMatch representation of the changes that will be made if an album or singleton's
object. 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 """Return a string representing the track index of the given
TrackInfo or Item object. TrackInfo or Item object.
""" """
@ -280,7 +450,7 @@ def show_change(cur_artist, cur_album, match):
index = track_info.index index = track_info.index
medium_index = track_info.medium_index medium_index = track_info.medium_index
medium = track_info.medium medium = track_info.medium
mediums = match.info.mediums mediums = self.match.info.mediums
else: else:
index = medium_index = track_info.track index = medium_index = track_info.track
medium = track_info.disc medium = track_info.disc
@ -294,205 +464,271 @@ def show_change(cur_artist, cur_album, match):
else: else:
return str(index) return str(index)
# Identify the album in question. def make_track_numbers(self, item, track_info):
if cur_artist != match.info.artist or \ """Format colored track indices."""
(cur_album != match.info.album and cur_track = self.format_index(item)
match.info.album != VARIOUS_ARTISTS): new_track = self.format_index(track_info)
artist_l, artist_r = cur_artist or '', match.info.artist templ = "(#{})"
album_l, album_r = cur_album or '', match.info.album changed = False
if artist_r == VARIOUS_ARTISTS: # Choose color based on change.
# Hide artists for VA releases. if cur_track != new_track:
artist_l, artist_r = '', '' changed = True
if item.track in (track_info.index, track_info.medium_index):
if config['artist_credit']: highlight_color = "text_highlight_minor"
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: else:
print_("Tagging:\n {0.artist} - {0.album}".format(match.info)) highlight_color = "text_highlight"
else:
highlight_color = "text_faint"
# Data URL. cur_track = templ.format(cur_track)
if match.info.data_url: new_track = templ.format(new_track)
print_('URL:\n %s' % match.info.data_url) lhs_track = ui.colorize(highlight_color, cur_track)
rhs_track = ui.colorize(highlight_color, new_track)
return lhs_track, rhs_track, changed
# Info line. @staticmethod
info = [] def make_track_titles(item, track_info):
# Similarity. """Format colored track titles."""
info.append('(Similarity: %s)' % dist_string(match.distance)) new_title = track_info.title
# Penalties. if not item.title.strip():
penalties = penalty_string(match.distance) # If there's no title, we use the filename. Don't colordiff.
if penalties: cur_title = displayable_path(os.path.basename(item.path))
info.append(penalties) return cur_title, new_title, True
# Disambiguation. else:
disambig = disambig_string(match.info) # If there is a title, highlight differences.
if disambig: cur_title = item.title.strip()
info.append(ui.colorize('text_highlight_minor', '(%s)' % disambig)) cur_col, new_col = ui.colordiff(cur_title, new_title)
print_(' '.join(info)) 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.
lhs_track, rhs_track, diff_track = \
self.make_track_numbers(item, track_info)
# Length change.
lhs_length, rhs_length, diff_length = \
self.make_track_lengths(item, track_info)
changed = diff_title or diff_track or diff_length
# 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])
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)
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. # Tracks.
pairs = list(match.mapping.items()) # match is an AlbumMatch named tuple, mapping is a dict
pairs.sort(key=lambda item_and_track_info: item_and_track_info[1].index) # 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 # Build up LHS and RHS for track difference display. The `lines` list
# contains ``(lhs, rhs, width)`` tuples where `width` is the length (in # contains `(left, right)` tuples.
# characters) of the uncolorized LHS.
lines = [] lines = []
medium = disctitle = None medium = disctitle = None
for item, track_info in pairs: for item, track_info in pairs:
# If the track is the first on a new medium, show medium
# Medium number and title. # number and title.
if medium != track_info.medium or disctitle != track_info.disctitle: if medium != track_info.medium or \
media = match.info.media or 'Media' disctitle != track_info.disctitle:
if match.info.mediums > 1 and track_info.disctitle: # Create header for new medium
lhs = '{} {}: {}'.format(media, track_info.medium, header = self.make_medium_info_line(track_info)
track_info.disctitle) if header != "":
elif match.info.mediums > 1: # Print tracks from previous medium
lhs = f'{media} {track_info.medium}' self.print_tracklist(lines)
elif track_info.disctitle: lines = []
lhs = f'{media}: {track_info.disctitle}' print_(self.indent_detail + header)
else: # Save new medium details for future comparison.
lhs = None
if lhs:
lines.append((lhs, '', 0))
medium, disctitle = track_info.medium, track_info.disctitle medium, disctitle = track_info.medium, track_info.disctitle
# Titles. if config["import"]["detail"]:
new_title = track_info.title # Construct the line tuple for the track.
if not item.title.strip(): left, right = self.make_line(item, track_info)
# If there's no title, we use the filename. lines.append((left, right))
cur_title = displayable_path(os.path.basename(item.path)) self.print_tracklist(lines)
lhs, rhs = cur_title, new_title
else:
cur_title = item.title.strip()
lhs, rhs = ui.colordiff(cur_title, new_title)
lhs_width = len(cur_title)
# 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
# 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
# Penalties.
penalties = penalty_string(match.distance.tracks[track_info])
if penalties:
rhs += ' %s' % penalties
if lhs != rhs:
lines.append((' * %s' % lhs, rhs, lhs_width))
elif config['import']['detail']:
lines.append((' * %s' % lhs, '', lhs_width))
# 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))
# Missing and unmatched tracks. # Missing and unmatched tracks.
if match.extra_tracks: if self.match.extra_tracks:
print_('Missing tracks ({}/{} - {:.1%}):'.format( print_(
len(match.extra_tracks), "Missing tracks ({0}/{1} - {2:.1%}):".format(
len(match.info.tracks), len(self.match.extra_tracks),
len(match.extra_tracks) / len(match.info.tracks) len(self.match.info.tracks),
)) len(self.match.extra_tracks) / len(self.match.info.tracks),
pad_width = max(len(track_info.title) for track_info in )
match.extra_tracks) )
for track_info in match.extra_tracks: for track_info in self.match.extra_tracks:
line = ' ! {0: <{width}} (#{1: >2})'.format(track_info.title, line = f" ! {track_info.title} (#{self.format_index(track_info)})"
format_index(track_info),
width=pad_width)
if track_info.length: if track_info.length:
line += ' (%s)' % ui.human_seconds_short(track_info.length) line += f" ({ui.human_seconds_short(track_info.length)})"
print_(ui.colorize('text_warning', line)) print_(ui.colorize("text_warning", line))
if match.extra_items: if self.match.extra_items:
print_('Unmatched tracks ({}):'.format(len(match.extra_items))) print_(f"Unmatched tracks ({len(self.match.extra_items)}):")
pad_width = max(len(item.title) for item in match.extra_items) for item in self.match.extra_items:
for item in match.extra_items: line = " ! {} (#{})".format(item.title, self.format_index(item))
line = ' ! {0: <{width}} (#{1: >2})'.format(item.title,
format_index(item),
width=pad_width)
if item.length: if item.length:
line += ' (%s)' % ui.human_seconds_short(item.length) line += " ({})".format(ui.human_seconds_short(item.length))
print_(ui.colorize('text_warning', line)) 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): def show_item_change(item, match):
"""Print out the change that would occur by tagging `item` with the """Print out the change that would occur by tagging `item` with the
metadata from `match`, a TrackMatch object. metadata from `match`, a TrackMatch object.
""" """
cur_artist, new_artist = item.artist, match.info.artist change = TrackChange(cur_artist=item.artist, cur_title=item.title,
cur_title, new_title = item.title, match.info.title match=match)
cur_album = item.album if item.album else "" # Print the match header.
new_album = match.info.album if match.info.album else "" change.show_match_header()
# Print the match details.
if (cur_artist != new_artist or cur_title != new_title change.show_match_details()
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))
def summarize_items(items, singleton): def summarize_items(items, singleton):
@ -625,36 +861,40 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None,
if not bypass_candidates: if not bypass_candidates:
# Display list of candidates. # Display list of candidates.
print_("")
print_('Finding tags for {} "{} - {}".'.format( print_('Finding tags for {} "{} - {}".'.format(
'track' if singleton else 'album', 'track' if singleton else 'album',
item.artist if singleton else cur_artist, item.artist if singleton else cur_artist,
item.title if singleton else cur_album, item.title if singleton else cur_album,
)) ))
print_('Candidates:') print_(ui.indent(2) + 'Candidates:')
for i, match in enumerate(candidates): for i, match in enumerate(candidates):
# Index, metadata, and distance. # Index, metadata, and distance.
line = [ index0 = "{0}.".format(i + 1)
'{}.'.format(i + 1), index = dist_colorize(index0, match.distance)
'{} - {}'.format( dist = "({:.1f}%)".format((1 - match.distance) * 100)
distance = dist_colorize(dist, match.distance)
metadata = "{0} - {1}".format(
match.info.artist, match.info.artist,
match.info.title if singleton else match.info.album, match.info.title if singleton else match.info.album,
), )
'({})'.format(dist_string(match.distance)), 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.
penalties = penalty_string(match.distance, 3) penalties = penalty_string(match.distance, 3)
if penalties: if penalties:
line.append(penalties) print_(ui.indent(13) + penalties)
# Disambiguation # Disambiguation
disambig = disambig_string(match.info) disambig = disambig_string(match.info)
if disambig: if disambig:
line.append(ui.colorize('text_highlight_minor', print_(ui.indent(13) + disambig)
'(%s)' % disambig))
print_(' '.join(line))
# Ask the user for a choice. # Ask the user for a choice.
sel = ui.input_options(choice_opts, sel = ui.input_options(choice_opts,
@ -754,8 +994,12 @@ class TerminalImportSession(importer.ImportSession):
""" """
# Show what we're tagging. # Show what we're tagging.
print_() 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 # Let plugins display info or prompt the user before we go through the
# process of selecting candidate. # process of selecting candidate.

View file

@ -0,0 +1,82 @@
# This file is part of beets.
# Copyright 2023, Max Rumpf.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""Plugin to rewrite fields based on a given query."""
from collections import defaultdict
import shlex
import confuse
from beets import ui
from beets.dbcore import AndQuery, query_from_strings
from beets.library import Item, Album
from beets.plugins import BeetsPlugin
def rewriter(field, rules):
"""Template field function factory.
Create a template field function that rewrites the given field
with the given rewriting rules.
``rules`` must be a list of (query, replacement) pairs.
"""
def fieldfunc(item):
value = item._values_fixed[field]
for query, replacement in rules:
if query.match(item):
# Rewrite activated.
return replacement
# Not activated; return original value.
return value
return fieldfunc
class AdvancedRewritePlugin(BeetsPlugin):
"""Plugin to rewrite fields based on a given query."""
def __init__(self):
"""Parse configuration and register template fields for rewriting."""
super().__init__()
template = confuse.Sequence({
'match': str,
'field': str,
'replacement': str,
})
# Gather all the rewrite rules for each field.
rules = defaultdict(list)
for rule in self.config.get(template):
query = query_from_strings(AndQuery, Item, prefixes={},
query_parts=shlex.split(rule['match']))
fieldname = rule['field']
replacement = rule['replacement']
if fieldname not in Item._fields:
raise ui.UserError(
"invalid field name (%s) in rewriter" % fieldname)
self._log.debug('adding template field {0}{1}',
fieldname, replacement)
rules[fieldname].append((query, replacement))
if fieldname == 'artist':
# Special case for the artist field: apply the same
# rewrite for "albumartist" as well.
rules['albumartist'].append((query, replacement))
# Replace each template field with the new rewriter function.
for fieldname, fieldrules in rules.items():
getter = rewriter(fieldname, fieldrules)
self.template_fields[fieldname] = getter
if fieldname in Album._fields:
self.album_template_fields[fieldname] = getter

View file

@ -399,10 +399,15 @@ class CoverArtArchive(RemoteArtSource):
if 'Front' not in item['types']: if 'Front' not in item['types']:
continue continue
if preferred_width: # If there is a pre-sized thumbnail of the desired size
yield item['thumbnails'][preferred_width] # we select it. Otherwise, we return the raw image.
else: image_url: str = item["image"]
yield item['image'] if preferred_width is not None:
if isinstance(item.get("thumbnails"), dict):
image_url = item["thumbnails"].get(
preferred_width, image_url
)
yield image_url
except KeyError: except KeyError:
pass pass
@ -422,7 +427,7 @@ class CoverArtArchive(RemoteArtSource):
yield self._candidate(url=url, match=Candidate.MATCH_EXACT) yield self._candidate(url=url, match=Candidate.MATCH_EXACT)
if 'releasegroup' in self.match_by and album.mb_releasegroupid: if 'releasegroup' in self.match_by and album.mb_releasegroupid:
for url in get_image_urls(release_group_url): for url in get_image_urls(release_group_url, preferred_width):
yield self._candidate(url=url, match=Candidate.MATCH_FALLBACK) yield self._candidate(url=url, match=Candidate.MATCH_FALLBACK)

View file

@ -9,6 +9,12 @@ Changelog goes here! Please add your entry to the bottom of one of the lists bel
With this release, beets now requires Python 3.7 or later (it removes support With this release, beets now requires Python 3.7 or later (it removes support
for Python 3.6). for Python 3.6).
Major new features:
* The beets importer UI received a major overhaul. Several new configuration
options are available for customizing layout and colors: :ref:`ui_options`.
:bug:`3721`
New features: New features:
* :ref:`update-cmd`: added ```-e``` flag for excluding fields from being updated. * :ref:`update-cmd`: added ```-e``` flag for excluding fields from being updated.
@ -125,6 +131,14 @@ New features:
* :doc:`/plugins/autobpm`: Add the `autobpm` plugin which uses Librosa to * :doc:`/plugins/autobpm`: Add the `autobpm` plugin which uses Librosa to
calculate the BPM of the audio. calculate the BPM of the audio.
:bug:`3856` :bug:`3856`
* :doc:`/plugins/fetchart`: Fix the error with CoverArtArchive where the
`maxwidth` option would not be used to download a pre-sized thumbnail for
release groups, as is already done with releases.
* :doc:`/plugins/fetchart`: Fix the error with CoverArtArchive where no cover
would be found when the `maxwidth` option matches a pre-sized thumbnail size,
but no thumbnail is provided by CAA. We now fallback to the raw image.
* :doc:`/plugins/advancedrewrite`: Add an advanced version of the `rewrite`
plugin which allows to replace fields based on a given library query.
Bug fixes: Bug fixes:

View file

@ -0,0 +1,39 @@
Advanced Rewrite Plugin
=======================
The ``advancedrewrite`` plugin lets you easily substitute values
in your templates and path formats, similarly to the :doc:`/plugins/rewrite`.
Please make sure to read the documentation of that plugin first.
The *advanced* rewrite plugin doesn't match the rewritten field itself,
but instead checks if the given item matches a :doc:`query </reference/query>`.
Only then, the field is replaced with the given value.
To use advanced field rewriting, first enable the ``advancedrewrite`` plugin
(see :ref:`using-plugins`).
Then, make a ``advancedrewrite:`` section in your config file to contain
your rewrite rules.
In contrast to the normal ``rewrite`` plugin, you need to provide a list
of replacement rule objects, each consisting of a query, a field name,
and the replacement value.
For example, to credit all songs of ODD EYE CIRCLE before 2023
to their original group name, you can use the following rule::
advancedrewrite:
- match: "mb_artistid:dec0f331-cb08-4c8e-9c9f-aeb1f0f6d88c year:..2022"
field: artist
replacement: "이달의 소녀 오드아이써클"
As a convenience, the plugin applies patterns for the ``artist`` field to the
``albumartist`` field as well. (Otherwise, you would probably want to duplicate
every rule for ``artist`` and ``albumartist``.)
A word of warning: This plugin theoretically only applies to templates and path
formats; it initially does not modify files' metadata tags or the values
tracked by beets' library database, but since it *rewrites all field lookups*,
it modifies the file's metadata anyway. See comments in issue :bug:`2786`.
As an alternative to this plugin the simpler :doc:`/plugins/rewrite` or
similar :doc:`/plugins/substitute` can be used.

View file

@ -41,7 +41,10 @@ file. The available options are:
considered as valid album art candidates. Default: 0. considered as valid album art candidates. Default: 0.
- **maxwidth**: A maximum image width to downscale fetched images if they are - **maxwidth**: A maximum image width to downscale fetched images if they are
too big. The resize operation reduces image width to at most ``maxwidth`` too big. The resize operation reduces image width to at most ``maxwidth``
pixels. The height is recomputed so that the aspect ratio is preserved. pixels. The height is recomputed so that the aspect ratio is preserved. See
the section on :ref:`cover-art-archive-maxwidth` below for additional
information regarding the Cover Art Archive source.
Default: 0 (no maximum is enforced).
- **quality**: The JPEG quality level to use when compressing images (when - **quality**: The JPEG quality level to use when compressing images (when
``maxwidth`` is set). This should be either a number from 1 to 100 or 0 to ``maxwidth`` is set). This should be either a number from 1 to 100 or 0 to
use the default quality. 6575 is usually a good starting point. The default use the default quality. 6575 is usually a good starting point. The default
@ -269,7 +272,21 @@ Spotify backend is enabled by default and will update album art if a valid Spoti
Cover Art URL Cover Art URL
''''''''''''' '''''''''''''
The `fetchart` plugin can also use a flexible attribute field ``cover_art_url`` where you can manually specify the image URL to be used as cover art. Any custom plugin can use this field to provide the cover art and ``fetchart`` will use it as a source. The `fetchart` plugin can also use a flexible attribute field ``cover_art_url``
where you can manually specify the image URL to be used as cover art. Any custom
plugin can use this field to provide the cover art and ``fetchart`` will use it
as a source.
.. _cover-art-archive-maxwidth:
Cover Art Archive Pre-sized Thumbnails
--------------------------------------
The CAA provides pre-sized thumbnails of width 250, 500, and 1200 pixels. If you
set the `maxwidth` option to one of these values, the corresponding image will
be downloaded, saving `beets` the need to scale down the image. It can also
speed up the downloading process, as some cover arts can sometimes be very
large.
Storing the Artwork's Source Storing the Artwork's Source
---------------------------- ----------------------------

View file

@ -61,6 +61,7 @@ following to your configuration::
absubmit absubmit
acousticbrainz acousticbrainz
advancedrewrite
albumtypes albumtypes
aura aura
autobpm autobpm
@ -246,6 +247,9 @@ Path Formats
:doc:`rewrite <rewrite>` :doc:`rewrite <rewrite>`
Substitute values in path formats. Substitute values in path formats.
:doc:`advancedrewrite <advancedrewrite>`
Substitute field values for items matching a query.
:doc:`substitute <substitute>` :doc:`substitute <substitute>`
As an alternative to :doc:`rewrite <rewrite>`, use this plugin. The main As an alternative to :doc:`rewrite <rewrite>`, use this plugin. The main
difference between them is that this plugin never modifies the files difference between them is that this plugin never modifies the files

View file

@ -398,6 +398,8 @@ Sets the albumartist for various-artist compilations. Defaults to ``'Various
Artists'`` (the MusicBrainz standard). Affects other sources, such as Artists'`` (the MusicBrainz standard). Affects other sources, such as
:doc:`/plugins/discogs`, too. :doc:`/plugins/discogs`, too.
.. _ui_options:
UI Options UI Options
---------- ----------
@ -419,6 +421,8 @@ support ANSI colors.
still respected, but a deprecation message will be shown until your still respected, but a deprecation message will be shown until your
top-level `color` configuration has been nested under `ui`. top-level `color` configuration has been nested under `ui`.
.. _colors:
colors colors
~~~~~~ ~~~~~~
@ -427,20 +431,77 @@ the ``color`` option is set to ``yes``. For example, you might have a section
in your configuration file that looks like this:: in your configuration file that looks like this::
ui: ui:
color: yes
colors: colors:
text_success: green text_success: ['bold', 'green']
text_warning: yellow text_warning: ['bold', 'yellow']
text_error: red text_error: ['bold', 'red']
text_highlight: red text_highlight: ['bold', 'red']
text_highlight_minor: lightgray text_highlight_minor: ['white']
action_default: turquoise action_default: ['bold', 'cyan']
action: blue action: ['bold', 'cyan']
# New colors after UI overhaul
text: ['normal']
text_faint: ['faint']
import_path: ['bold', '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: ['white']
Available colors: black, darkred, darkgreen, brown (darkyellow), darkblue, Available colors: black, darkred, darkgreen, brown (darkyellow), darkblue,
purple (darkmagenta), teal (darkcyan), lightgray, darkgray, red, green, purple (darkmagenta), teal (darkcyan), lightgray, darkgray, red, green,
yellow, blue, fuchsia (magenta), turquoise (cyan), white yellow, blue, fuchsia (magenta), turquoise (cyan), white
Legacy UI colors config directive used strings. If any colors value is still a
string instead of a list, it will be translated to list automatically. For
example ``blue`` will become ``['blue']``.
terminal_width
~~~~~~~~~~~~~~
Controls line wrapping. Defaults to ``80`` characters::
ui:
terminal_width: 80
length_diff_thresh
~~~~~~~~~~~~~~~~~~
Beets compares the length of the imported track with the length the metadata
source provides. If any tracks differ by at least ``length_diff_thresh``
seconds, they will be colored with ``text_highlight``. Below this threshold,
different track lengths are colored with ``text_highlight_minor``.
``length_diff_thresh`` does not impact which releases are selected in
autotagger matching or distance score calculation (see :ref:`match-config`,
``distance_weights`` and :ref:`colors`)::
ui:
length_diff_thresh: 10.0
import
~~~~~~
When importing, beets will read several options to configure the visuals of the
import dialogue. There are two layouts controlling how horizontal space and
line wrapping is dealt with: ``column`` and ``newline``. The indentation of the
respective elements of the import UI can also be configured. For example
setting ``4`` for ``match_header`` will indent the very first block of a
proposed match by five characters in the terminal::
ui:
import:
indentation:
match_header: 4
match_details: 4
match_tracklist: 7
layout: newline
Importer Options Importer Options
---------------- ----------------

View file

@ -130,6 +130,39 @@ class CAAHelper():
} }
], ],
"release": "https://musicbrainz.org/release/releaseid" "release": "https://musicbrainz.org/release/releaseid"
}"""
RESPONSE_RELEASE_WITHOUT_THUMBNAILS = """{
"images": [
{
"approved": false,
"back": false,
"comment": "GIF",
"edit": 12345,
"front": true,
"id": 12345,
"image": "http://coverartarchive.org/release/rid/12345.gif",
"types": [
"Front"
]
},
{
"approved": false,
"back": false,
"comment": "",
"edit": 12345,
"front": false,
"id": 12345,
"image": "http://coverartarchive.org/release/rid/12345.jpg",
"thumbnails": {
"large": "http://coverartarchive.org/release/rgid/12345-500.jpg",
"small": "http://coverartarchive.org/release/rgid/12345-250.jpg"
},
"types": [
"Front"
]
}
],
"release": "https://musicbrainz.org/release/releaseid"
}""" }"""
RESPONSE_GROUP = """{ RESPONSE_GROUP = """{
"images": [ "images": [
@ -155,6 +188,23 @@ class CAAHelper():
], ],
"release": "https://musicbrainz.org/release/release-id" "release": "https://musicbrainz.org/release/release-id"
}""" }"""
RESPONSE_GROUP_WITHOUT_THUMBNAILS = """{
"images": [
{
"approved": false,
"back": false,
"comment": "",
"edit": 12345,
"front": true,
"id": 12345,
"image": "http://coverartarchive.org/release/releaseid/12345.jpg",
"types": [
"Front"
]
}
],
"release": "https://musicbrainz.org/release/release-id"
}"""
def mock_caa_response(self, url, json): def mock_caa_response(self, url, json):
responses.add(responses.GET, url, body=json, responses.add(responses.GET, url, body=json,
@ -521,6 +571,42 @@ class CoverArtArchiveTest(UseThePlugin, CAAHelper):
self.assertEqual(len(responses.calls), 2) self.assertEqual(len(responses.calls), 2)
self.assertEqual(responses.calls[0].request.url, self.RELEASE_URL) self.assertEqual(responses.calls[0].request.url, self.RELEASE_URL)
def test_fetchart_uses_caa_pre_sized_maxwidth_thumbs(self):
# CAA provides pre-sized thumbnails of width 250px, 500px, and 1200px
# We only test with one of them here
maxwidth = 1200
self.settings = Settings(maxwidth=maxwidth)
album = _common.Bag(
mb_albumid=self.MBID_RELASE, mb_releasegroupid=self.MBID_GROUP
)
self.mock_caa_response(self.RELEASE_URL, self.RESPONSE_RELEASE)
self.mock_caa_response(self.GROUP_URL, self.RESPONSE_GROUP)
candidates = list(self.source.get(album, self.settings, []))
self.assertEqual(len(candidates), 3)
for candidate in candidates:
self.assertTrue(f"-{maxwidth}.jpg" in candidate.url)
def test_caa_finds_image_if_maxwidth_is_set_and_thumbnails_is_empty(self):
# CAA provides pre-sized thumbnails of width 250px, 500px, and 1200px
# We only test with one of them here
maxwidth = 1200
self.settings = Settings(maxwidth=maxwidth)
album = _common.Bag(
mb_albumid=self.MBID_RELASE, mb_releasegroupid=self.MBID_GROUP
)
self.mock_caa_response(
self.RELEASE_URL, self.RESPONSE_RELEASE_WITHOUT_THUMBNAILS
)
self.mock_caa_response(
self.GROUP_URL, self.RESPONSE_GROUP_WITHOUT_THUMBNAILS,
)
candidates = list(self.source.get(album, self.settings, []))
self.assertEqual(len(candidates), 3)
for candidate in candidates:
self.assertFalse(f"-{maxwidth}.jpg" in candidate.url)
class FanartTVTest(UseThePlugin): class FanartTVTest(UseThePlugin):
RESPONSE_MULTIPLE = """{ RESPONSE_MULTIPLE = """{

View file

@ -1184,59 +1184,140 @@ 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', cur_artist='the artist', cur_album='the album',
dist=0.1): dist=0.1):
"""Return an unicode string representing the changes""" """Return an unicode string representing the changes"""
items = items or self.items items = items or self.items
info = info or self.info info = info or self.info
mapping = dict(zip(items, info.tracks)) mapping = dict(zip(items, info.tracks))
config['ui']['color'] = False config['ui']['color'] = color
album_dist = distance(items, info, mapping) config['import']['detail'] = True
album_dist._penalties = {'album': [dist]} change_dist = distance(items, info, mapping)
change_dist._penalties = {'album': [dist], 'artist': [dist]}
commands.show_change( commands.show_change(
cur_artist, cur_artist,
cur_album, cur_album,
autotag.AlbumMatch(album_dist, info, mapping, set(), set()), autotag.AlbumMatch(change_dist, info, mapping, set(), set()),
) )
return self.io.getoutput().lower() return self.io.getoutput().lower()
def test_null_change(self): def test_null_change(self):
msg = self._show_change() msg = self._show_change()
self.assertTrue('similarity: 90' in msg) self.assertTrue('match (90.0%)' in msg)
self.assertTrue('tagging:' in msg) self.assertTrue('album, artist' in msg)
def test_album_data_change(self): def test_album_data_change(self):
msg = self._show_change(cur_artist='another artist', msg = self._show_change(cur_artist='another artist',
cur_album='another album') 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): def test_item_data_change(self):
self.items[0].title = 'different' self.items[0].title = 'different'
msg = self._show_change() 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): def test_item_data_change_with_unicode(self):
self.items[0].title = 'caf\xe9' self.items[0].title = 'caf\xe9'
msg = self._show_change() 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): def test_album_data_change_with_unicode(self):
msg = self._show_change(cur_artist='caf\xe9', msg = self._show_change(cur_artist=u'caf\xe9',
cur_album='another album') cur_album=u'another album')
self.assertTrue('correcting tags from:' in msg) self.assertTrue(u'caf\xe9' in msg and 'the artist' in msg)
def test_item_data_change_title_missing(self): def test_item_data_change_title_missing(self):
self.items[0].title = '' self.items[0].title = ''
msg = re.sub(r' +', ' ', self._show_change()) 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): def test_item_data_change_title_missing_with_unicode_filename(self):
self.items[0].title = '' self.items[0].title = ''
self.items[0].path = '/path/to/caf\xe9.mp3'.encode() self.items[0].path = '/path/to/caf\xe9.mp3'.encode()
msg = re.sub(r' +', ' ', self._show_change()) msg = re.sub(r' +', ' ', self._show_change())
self.assertTrue('caf\xe9.mp3 -> the title' in msg or self.assertTrue(u'caf\xe9.mp3' in msg or
'caf.mp3 ->' in msg) u'caf.mp3' in msg)
def test_colorize(self):
self.assertEqual("test", ui.uncolorize("test"))
txt = ui.uncolorize("\x1b[31mtest\x1b[39;49;00m")
self.assertEqual("test", txt)
txt = ui.uncolorize("\x1b[31mtest\x1b[39;49;00m test")
self.assertEqual("test test", txt)
txt = ui.uncolorize("\x1b[31mtest\x1b[39;49;00mtest")
self.assertEqual("testtest", txt)
txt = ui.uncolorize("test \x1b[31mtest\x1b[39;49;00m test")
self.assertEqual("test test test", txt)
def test_color_split(self):
exp = ("test", "")
res = ui.color_split("test", 5)
self.assertEqual(exp, res)
exp = ("\x1b[31mtes\x1b[39;49;00m", "\x1b[31mt\x1b[39;49;00m")
res = ui.color_split("\x1b[31mtest\x1b[39;49;00m", 3)
self.assertEqual(exp, res)
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)
# Test single color, different spacing
colored_text = "\x1b[31mtest\x1b[39;49;00mtest test test"
# ToDo: fix color_len to handle mid-text color escapes, and thus
# split colored texts over newlines (potentially with dashes?)
split_txt = ["\x1b[31mtest\x1b[39;49;00mt", "est", "test", "test"]
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)) @patch('beets.library.Item.try_filesize', Mock(return_value=987))