mirror of
https://github.com/beetbox/beets.git
synced 2025-12-07 00:53:08 +01:00
Merge remote-tracking branch 'upstream/master' into spotify_timeout
This commit is contained in:
commit
66bf0023ea
12 changed files with 1502 additions and 365 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"]:
|
||||||
|
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(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')
|
||||||
|
# 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)
|
||||||
|
else:
|
||||||
return text
|
return text
|
||||||
|
|
||||||
global COLORS
|
|
||||||
if not COLORS:
|
def uncolorize(colored_text):
|
||||||
COLORS = {name:
|
"""Remove colors from a string."""
|
||||||
config['ui']['colors'][name].as_str()
|
# Define a regular expression to match ANSI codes.
|
||||||
for name in COLOR_NAMES}
|
# See: http://stackoverflow.com/a/2187024/1382707
|
||||||
# In case a 3rd party plugin is still passing the actual color ('red')
|
# Explanation of regular expression:
|
||||||
# instead of the abstract color name ('text_error')
|
# \x1b - matches ESC character
|
||||||
color = COLORS.get(color_name)
|
# \[ - matches opening square bracket
|
||||||
if not color:
|
# [;\d]* - matches a sequence consisting of one or more digits or
|
||||||
log.debug('Invalid color_name: {0}', color_name)
|
# semicola
|
||||||
color = color_name
|
# [A-Za-z] - matches a letter
|
||||||
return _colorize(color, text)
|
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',
|
def color_split(colored_text, index):
|
||||||
minor_highlight='text_highlight_minor'):
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
))
|
return ''
|
||||||
else:
|
|
||||||
disambig.append(info.media)
|
|
||||||
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):
|
return ', '.join(disambig)
|
||||||
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)
|
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:
|
|
||||||
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}'
|
|
||||||
else:
|
else:
|
||||||
lhs = None
|
highlight_color = "text_highlight"
|
||||||
if lhs:
|
else:
|
||||||
lines.append((lhs, '', 0))
|
highlight_color = "text_faint"
|
||||||
medium, disctitle = track_info.medium, track_info.disctitle
|
|
||||||
|
|
||||||
# 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
|
new_title = track_info.title
|
||||||
if not item.title.strip():
|
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))
|
cur_title = displayable_path(os.path.basename(item.path))
|
||||||
lhs, rhs = cur_title, new_title
|
return cur_title, new_title, True
|
||||||
else:
|
else:
|
||||||
|
# If there is a title, highlight differences.
|
||||||
cur_title = item.title.strip()
|
cur_title = item.title.strip()
|
||||||
lhs, rhs = ui.colordiff(cur_title, new_title)
|
cur_col, new_col = ui.colordiff(cur_title, new_title)
|
||||||
lhs_width = len(cur_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.
|
# Track number change.
|
||||||
cur_track, new_track = format_index(item), format_index(track_info)
|
lhs_track, rhs_track, diff_track = \
|
||||||
if cur_track != new_track:
|
self.make_track_numbers(item, track_info)
|
||||||
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.
|
# Length change.
|
||||||
if item.length and track_info.length and \
|
lhs_length, rhs_length, diff_length = \
|
||||||
abs(item.length - track_info.length) > \
|
self.make_track_lengths(item, track_info)
|
||||||
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.
|
changed = diff_title or diff_track or diff_length
|
||||||
penalties = penalty_string(match.distance.tracks[track_info])
|
|
||||||
if penalties:
|
|
||||||
rhs += ' %s' % penalties
|
|
||||||
|
|
||||||
if lhs != rhs:
|
# Construct lhs and rhs dicts.
|
||||||
lines.append((' * %s' % lhs, rhs, lhs_width))
|
# Previously, we printed the penalties, however this is no longer
|
||||||
elif config['import']['detail']:
|
# the case, thus the 'info' dictionary is unneeded.
|
||||||
lines.append((' * %s' % lhs, '', lhs_width))
|
# penalties = penalty_string(self.match.distance.tracks[track_info])
|
||||||
|
|
||||||
# Print each track in two columns, or across two lines.
|
prefix = ui.colorize("changed", "\u2260 ") if changed else "* "
|
||||||
col_width = (ui.term_width() - len(''.join([' * ', ' -> ']))) // 2
|
lhs = {
|
||||||
if lines:
|
"prefix": prefix + lhs_track + " ",
|
||||||
max_width = max(w for _, _, w in lines)
|
"contents": lhs_title,
|
||||||
for lhs, rhs, lhs_width in lines:
|
"suffix": " " + lhs_length,
|
||||||
if not rhs:
|
}
|
||||||
print_(lhs)
|
rhs = {"prefix": "", "contents": "", "suffix": ""}
|
||||||
elif max_width > col_width:
|
if not changed:
|
||||||
print_(f'{lhs} ->\n {rhs}')
|
# Only return the left side, as nothing changed.
|
||||||
else:
|
return (lhs, rhs)
|
||||||
pad = max_width - lhs_width
|
else:
|
||||||
print_('{}{} -> {}'.format(lhs, ' ' * pad, rhs))
|
# 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.
|
def print_tracklist(self, lines):
|
||||||
if match.extra_tracks:
|
"""Calculates column widths for tracks stored as line tuples:
|
||||||
print_('Missing tracks ({}/{} - {:.1%}):'.format(
|
(left, right). Then prints each line of tracklist.
|
||||||
len(match.extra_tracks),
|
"""
|
||||||
len(match.info.tracks),
|
if len(lines) == 0:
|
||||||
len(match.extra_tracks) / len(match.info.tracks)
|
# If no lines provided, e.g. details not required, do nothing.
|
||||||
))
|
return
|
||||||
pad_width = max(len(track_info.title) for track_info in
|
|
||||||
match.extra_tracks)
|
def get_width(side):
|
||||||
for track_info in match.extra_tracks:
|
"""Return the width of left or right in uncolorized characters."""
|
||||||
line = ' ! {0: <{width}} (#{1: >2})'.format(track_info.title,
|
try:
|
||||||
format_index(track_info),
|
return len(
|
||||||
width=pad_width)
|
ui.uncolorize(
|
||||||
if track_info.length:
|
" ".join([side["prefix"],
|
||||||
line += ' (%s)' % ui.human_seconds_short(track_info.length)
|
side["contents"],
|
||||||
print_(ui.colorize('text_warning', line))
|
side["suffix"]])
|
||||||
if match.extra_items:
|
)
|
||||||
print_('Unmatched tracks ({}):'.format(len(match.extra_items)))
|
)
|
||||||
pad_width = max(len(item.title) for item in match.extra_items)
|
except KeyError:
|
||||||
for item in match.extra_items:
|
# An empty dictionary -> Nothing to report
|
||||||
line = ' ! {0: <{width}} (#{1: >2})'.format(item.title,
|
return 0
|
||||||
format_index(item),
|
|
||||||
width=pad_width)
|
# Check how to fit content into terminal window
|
||||||
if item.length:
|
indent_width = len(self.indent_tracklist)
|
||||||
line += ' (%s)' % ui.human_seconds_short(item.length)
|
terminal_width = ui.term_width()
|
||||||
print_(ui.colorize('text_warning', line))
|
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):
|
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)
|
||||||
match.info.artist,
|
distance = dist_colorize(dist, match.distance)
|
||||||
match.info.title if singleton else match.info.album,
|
metadata = "{0} - {1}".format(
|
||||||
),
|
match.info.artist,
|
||||||
'({})'.format(dist_string(match.distance)),
|
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.
|
||||||
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.
|
||||||
|
|
|
||||||
82
beetsplug/advancedrewrite.py
Normal file
82
beetsplug/advancedrewrite.py
Normal 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
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
||||||
|
|
|
||||||
39
docs/plugins/advancedrewrite.rst
Normal file
39
docs/plugins/advancedrewrite.rst
Normal 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.
|
||||||
|
|
@ -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. 65–75 is usually a good starting point. The default
|
use the default quality. 65–75 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
|
||||||
----------------------------
|
----------------------------
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
----------------
|
----------------
|
||||||
|
|
|
||||||
|
|
@ -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 = """{
|
||||||
|
|
|
||||||
113
test/test_ui.py
113
test/test_ui.py
|
|
@ -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))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue