mirror of
https://github.com/beetbox/beets.git
synced 2025-12-06 16:42:42 +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
|
||||
color: yes
|
||||
colors:
|
||||
text_success: green
|
||||
text_warning: yellow
|
||||
text_error: red
|
||||
text_highlight: red
|
||||
text_highlight_minor: lightgray
|
||||
action_default: turquoise
|
||||
action: blue
|
||||
text_success: ['bold', 'green']
|
||||
text_warning: ['bold', 'yellow']
|
||||
text_error: ['bold', 'red']
|
||||
text_highlight: ['bold', 'red']
|
||||
text_highlight_minor: ['white']
|
||||
action_default: ['bold', 'cyan']
|
||||
action: ['bold', '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_album: $albumartist - $album
|
||||
|
|
@ -176,3 +197,5 @@ match:
|
|||
ignore_video_tracks: yes
|
||||
track_length_grace: 10
|
||||
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.
|
||||
|
||||
|
||||
def indent(count):
|
||||
"""Returns a string with `count` many spaces.
|
||||
"""
|
||||
return " " * count
|
||||
|
||||
|
||||
def input_(prompt=None):
|
||||
"""Like `input`, but decodes the result to a Unicode string.
|
||||
Raises a UserError if stdin is not available. The prompt is sent to
|
||||
|
|
@ -267,8 +274,11 @@ def input_options(options, require=False, prompt=None, fallback_prompt=None,
|
|||
show_letter)
|
||||
|
||||
# Insert the highlighted letter back into the word.
|
||||
descr_color = "action_default" if is_default else "action_description"
|
||||
capitalized.append(
|
||||
option[:index] + show_letter + option[index + 1:]
|
||||
colorize(descr_color, option[:index])
|
||||
+ show_letter
|
||||
+ colorize(descr_color, option[index + 1:])
|
||||
)
|
||||
display_letters.append(found_letter.upper())
|
||||
|
||||
|
|
@ -301,15 +311,16 @@ def input_options(options, require=False, prompt=None, fallback_prompt=None,
|
|||
prompt_part_lengths += [len(s) for s in options]
|
||||
|
||||
# Wrap the query text.
|
||||
prompt = ''
|
||||
# Start prompt with U+279C: Heavy Round-Tipped Rightwards Arrow
|
||||
prompt = colorize("action", "\u279C ")
|
||||
line_length = 0
|
||||
for i, (part, length) in enumerate(zip(prompt_parts,
|
||||
prompt_part_lengths)):
|
||||
# Add punctuation.
|
||||
if i == len(prompt_parts) - 1:
|
||||
part += '?'
|
||||
part += colorize("action_description", "?")
|
||||
else:
|
||||
part += ','
|
||||
part += colorize("action_description", ",")
|
||||
length += 1
|
||||
|
||||
# Choose either the current line or the beginning of the next.
|
||||
|
|
@ -368,10 +379,12 @@ def input_yn(prompt, require=False):
|
|||
"""Prompts the user for a "yes" or "no" response. The default is
|
||||
"yes" unless `require` is `True`, in which case there is no default.
|
||||
"""
|
||||
sel = input_options(
|
||||
('y', 'n'), require, prompt, 'Enter Y or N:'
|
||||
# Start prompt with U+279C: Heavy Round-Tipped Rightwards Arrow
|
||||
yesno = colorize("action", "\u279C ") + colorize(
|
||||
"action_description", "Enter Y or N:"
|
||||
)
|
||||
return sel == 'y'
|
||||
sel = input_options(("y", "n"), require, prompt, yesno)
|
||||
return sel == "y"
|
||||
|
||||
|
||||
def input_select_objects(prompt, objs, rep, prompt_all=None):
|
||||
|
|
@ -465,51 +478,102 @@ def human_seconds_short(interval):
|
|||
# https://bitbucket.org/birkenfeld/pygments-main/src/default/pygments/console.py
|
||||
# (pygments is by Tim Hatch, Armin Ronacher, et al.)
|
||||
COLOR_ESCAPE = "\x1b["
|
||||
DARK_COLORS = {
|
||||
"black": 0,
|
||||
"darkred": 1,
|
||||
"darkgreen": 2,
|
||||
"brown": 3,
|
||||
"darkyellow": 3,
|
||||
"darkblue": 4,
|
||||
"purple": 5,
|
||||
"darkmagenta": 5,
|
||||
"teal": 6,
|
||||
"darkcyan": 6,
|
||||
"lightgray": 7
|
||||
LEGACY_COLORS = {
|
||||
"black": ["black"],
|
||||
"darkred": ["red"],
|
||||
"darkgreen": ["green"],
|
||||
"brown": ["yellow"],
|
||||
"darkyellow": ["yellow"],
|
||||
"darkblue": ["blue"],
|
||||
"purple": ["magenta"],
|
||||
"darkmagenta": ["magenta"],
|
||||
"teal": ["cyan"],
|
||||
"darkcyan": ["cyan"],
|
||||
"lightgray": ["white"],
|
||||
"darkgray": ["bold", "black"],
|
||||
"red": ["bold", "red"],
|
||||
"green": ["bold", "green"],
|
||||
"yellow": ["bold", "yellow"],
|
||||
"blue": ["bold", "blue"],
|
||||
"fuchsia": ["bold", "magenta"],
|
||||
"magenta": ["bold", "magenta"],
|
||||
"turquoise": ["bold", "cyan"],
|
||||
"cyan": ["bold", "cyan"],
|
||||
"white": ["bold", "white"],
|
||||
}
|
||||
LIGHT_COLORS = {
|
||||
"darkgray": 0,
|
||||
"red": 1,
|
||||
"green": 2,
|
||||
"yellow": 3,
|
||||
"blue": 4,
|
||||
"fuchsia": 5,
|
||||
"magenta": 5,
|
||||
"turquoise": 6,
|
||||
"cyan": 6,
|
||||
"white": 7
|
||||
# All ANSI Colors.
|
||||
ANSI_CODES = {
|
||||
# Styles.
|
||||
"normal": 0,
|
||||
"bold": 1,
|
||||
"faint": 2,
|
||||
# "italic": 3,
|
||||
"underline": 4,
|
||||
# "blink_slow": 5,
|
||||
# "blink_rapid": 6,
|
||||
"inverse": 7,
|
||||
# "conceal": 8,
|
||||
# "crossed_out": 9
|
||||
# Text colors.
|
||||
"black": 30,
|
||||
"red": 31,
|
||||
"green": 32,
|
||||
"yellow": 33,
|
||||
"blue": 34,
|
||||
"magenta": 35,
|
||||
"cyan": 36,
|
||||
"white": 37,
|
||||
# Background colors.
|
||||
"bg_black": 40,
|
||||
"bg_red": 41,
|
||||
"bg_green": 42,
|
||||
"bg_yellow": 43,
|
||||
"bg_blue": 44,
|
||||
"bg_magenta": 45,
|
||||
"bg_cyan": 46,
|
||||
"bg_white": 47,
|
||||
}
|
||||
RESET_COLOR = COLOR_ESCAPE + "39;49;00m"
|
||||
|
||||
# These abstract COLOR_NAMES are lazily mapped on to the actual color in COLORS
|
||||
# as they are defined in the configuration files, see function: colorize
|
||||
COLOR_NAMES = ['text_success', 'text_warning', 'text_error', 'text_highlight',
|
||||
'text_highlight_minor', 'action_default', 'action']
|
||||
COLOR_NAMES = [
|
||||
"text_success",
|
||||
"text_warning",
|
||||
"text_error",
|
||||
"text_highlight",
|
||||
"text_highlight_minor",
|
||||
"action_default",
|
||||
"action",
|
||||
# New Colors
|
||||
"text",
|
||||
"text_faint",
|
||||
"import_path",
|
||||
"import_path_items",
|
||||
"action_description",
|
||||
"added",
|
||||
"removed",
|
||||
"changed",
|
||||
"added_highlight",
|
||||
"removed_highlight",
|
||||
"changed_highlight",
|
||||
"text_diff_added",
|
||||
"text_diff_removed",
|
||||
"text_diff_changed",
|
||||
]
|
||||
COLORS = None
|
||||
|
||||
|
||||
def _colorize(color, text):
|
||||
"""Returns a string that prints the given text in the given color
|
||||
in a terminal that is ANSI color-aware. The color must be something
|
||||
in DARK_COLORS or LIGHT_COLORS.
|
||||
in a terminal that is ANSI color-aware. The color must be a list of strings
|
||||
from ANSI_CODES.
|
||||
"""
|
||||
if color in DARK_COLORS:
|
||||
escape = COLOR_ESCAPE + "%im" % (DARK_COLORS[color] + 30)
|
||||
elif color in LIGHT_COLORS:
|
||||
escape = COLOR_ESCAPE + "%i;01m" % (LIGHT_COLORS[color] + 30)
|
||||
else:
|
||||
raise ValueError('no such color %s', color)
|
||||
# Construct escape sequence to be put before the text by iterating
|
||||
# over all "ANSI codes" in `color`.
|
||||
escape = ""
|
||||
for code in color:
|
||||
escape = escape + COLOR_ESCAPE + "%im" % ANSI_CODES[code]
|
||||
return escape + text + RESET_COLOR
|
||||
|
||||
|
||||
|
|
@ -517,45 +581,128 @@ def colorize(color_name, text):
|
|||
"""Colorize text if colored output is enabled. (Like _colorize but
|
||||
conditional.)
|
||||
"""
|
||||
if not config['ui']['color'] or 'NO_COLOR' in os.environ.keys():
|
||||
if config["ui"]["color"]:
|
||||
global COLORS
|
||||
if not COLORS:
|
||||
# Read all color configurations and set global variable COLORS.
|
||||
COLORS = dict()
|
||||
for name in COLOR_NAMES:
|
||||
# Convert legacy color definitions (strings) into the new
|
||||
# list-based color definitions. Do this by trying to read the
|
||||
# color definition from the configuration as unicode - if this
|
||||
# is successful, the color definition is a legacy definition
|
||||
# and has to be converted.
|
||||
try:
|
||||
color_def = config["ui"]["colors"][name].get(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
|
||||
|
||||
global COLORS
|
||||
if not COLORS:
|
||||
COLORS = {name:
|
||||
config['ui']['colors'][name].as_str()
|
||||
for name in COLOR_NAMES}
|
||||
# In case a 3rd party plugin is still passing the actual color ('red')
|
||||
# instead of the abstract color name ('text_error')
|
||||
color = COLORS.get(color_name)
|
||||
if not color:
|
||||
log.debug('Invalid color_name: {0}', color_name)
|
||||
color = color_name
|
||||
return _colorize(color, text)
|
||||
|
||||
def uncolorize(colored_text):
|
||||
"""Remove colors from a string."""
|
||||
# Define a regular expression to match ANSI codes.
|
||||
# See: http://stackoverflow.com/a/2187024/1382707
|
||||
# Explanation of regular expression:
|
||||
# \x1b - matches ESC character
|
||||
# \[ - matches opening square bracket
|
||||
# [;\d]* - matches a sequence consisting of one or more digits or
|
||||
# semicola
|
||||
# [A-Za-z] - matches a letter
|
||||
ansi_code_regex = re.compile(r"\x1b\[[;\d]*[A-Za-z]", re.VERBOSE)
|
||||
# Strip ANSI codes from `colored_text` using the regular expression.
|
||||
text = ansi_code_regex.sub("", colored_text)
|
||||
return text
|
||||
|
||||
|
||||
def _colordiff(a, b, highlight='text_highlight',
|
||||
minor_highlight='text_highlight_minor'):
|
||||
def color_split(colored_text, index):
|
||||
ansi_code_regex = re.compile(r"(\x1b\[[;\d]*[A-Za-z])", re.VERBOSE)
|
||||
length = 0
|
||||
pre_split = ""
|
||||
post_split = ""
|
||||
found_color_code = None
|
||||
found_split = False
|
||||
for part in ansi_code_regex.split(colored_text):
|
||||
# Count how many real letters we have passed
|
||||
length += color_len(part)
|
||||
if found_split:
|
||||
post_split += part
|
||||
else:
|
||||
if ansi_code_regex.match(part):
|
||||
# This is a color code
|
||||
if part == RESET_COLOR:
|
||||
found_color_code = None
|
||||
else:
|
||||
found_color_code = part
|
||||
pre_split += part
|
||||
else:
|
||||
if index < length:
|
||||
# Found part with our split in.
|
||||
split_index = index - (length - color_len(part))
|
||||
found_split = True
|
||||
if found_color_code:
|
||||
pre_split += part[:split_index] + RESET_COLOR
|
||||
post_split += found_color_code + part[split_index:]
|
||||
else:
|
||||
pre_split += part[:split_index]
|
||||
post_split += part[split_index:]
|
||||
else:
|
||||
# Not found, add this part to the pre split
|
||||
pre_split += part
|
||||
return pre_split, post_split
|
||||
|
||||
|
||||
def color_len(colored_text):
|
||||
"""Measure the length of a string while excluding ANSI codes from the
|
||||
measurement. The standard `len(my_string)` method also counts ANSI codes
|
||||
to the string length, which is counterproductive when layouting a
|
||||
Terminal interface.
|
||||
"""
|
||||
# Return the length of the uncolored string.
|
||||
return len(uncolorize(colored_text))
|
||||
|
||||
|
||||
def _colordiff(a, b):
|
||||
"""Given two values, return the same pair of strings except with
|
||||
their differences highlighted in the specified color. Strings are
|
||||
highlighted intelligently to show differences; other values are
|
||||
stringified and highlighted in their entirety.
|
||||
"""
|
||||
if not isinstance(a, str) \
|
||||
or not isinstance(b, str):
|
||||
# Non-strings: use ordinary equality.
|
||||
a = str(a)
|
||||
b = str(b)
|
||||
if a == b:
|
||||
return a, b
|
||||
else:
|
||||
return colorize(highlight, a), colorize(highlight, b)
|
||||
|
||||
# First, convert paths to readable format
|
||||
if isinstance(a, bytes) or isinstance(b, bytes):
|
||||
# A path field.
|
||||
a = util.displayable_path(a)
|
||||
b = util.displayable_path(b)
|
||||
|
||||
if not isinstance(a, str) or not isinstance(b, str):
|
||||
# Non-strings: use ordinary equality.
|
||||
if a == b:
|
||||
return str(a), str(b)
|
||||
else:
|
||||
return (
|
||||
colorize("text_diff_removed", str(a)),
|
||||
colorize("text_diff_added", str(b))
|
||||
)
|
||||
|
||||
a_out = []
|
||||
b_out = []
|
||||
|
||||
|
|
@ -567,31 +714,36 @@ def _colordiff(a, b, highlight='text_highlight',
|
|||
b_out.append(b[b_start:b_end])
|
||||
elif op == 'insert':
|
||||
# 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':
|
||||
# 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':
|
||||
# Right and left differ. Colorise with second highlight if
|
||||
# it's just a case change.
|
||||
if a[a_start:a_end].lower() != b[b_start:b_end].lower():
|
||||
color = highlight
|
||||
a_color = "text_diff_removed"
|
||||
b_color = "text_diff_added"
|
||||
else:
|
||||
color = minor_highlight
|
||||
a_out.append(colorize(color, a[a_start:a_end]))
|
||||
b_out.append(colorize(color, b[b_start:b_end]))
|
||||
a_color = b_color = "text_highlight_minor"
|
||||
a_out.append(colorize(a_color,
|
||||
a[a_start:a_end]))
|
||||
b_out.append(colorize(b_color,
|
||||
b[b_start:b_end]))
|
||||
else:
|
||||
assert False
|
||||
|
||||
return ''.join(a_out), ''.join(b_out)
|
||||
|
||||
|
||||
def colordiff(a, b, highlight='text_highlight'):
|
||||
def colordiff(a, b):
|
||||
"""Colorize differences between two values if color is enabled.
|
||||
(Like _colordiff but conditional.)
|
||||
"""
|
||||
if config['ui']['color']:
|
||||
return _colordiff(a, b, highlight)
|
||||
return _colordiff(a, b)
|
||||
else:
|
||||
return str(a), str(b)
|
||||
|
||||
|
|
@ -648,6 +800,335 @@ def term_width():
|
|||
return width
|
||||
|
||||
|
||||
def split_into_lines(string, width_tuple):
|
||||
"""Splits string into a list of substrings at whitespace.
|
||||
|
||||
`width_tuple` is a 3-tuple of `(first_width, last_width, middle_width)`.
|
||||
The first substring has a length not longer than `first_width`, the last
|
||||
substring has a length not longer than `last_width`, and all other
|
||||
substrings have a length not longer than `middle_width`.
|
||||
`string` may contain ANSI codes at word borders.
|
||||
"""
|
||||
first_width, middle_width, last_width = width_tuple
|
||||
words = []
|
||||
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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -22,10 +22,12 @@ import re
|
|||
from platform import python_version
|
||||
from collections import namedtuple, Counter
|
||||
from itertools import chain
|
||||
from typing import Sequence
|
||||
|
||||
import beets
|
||||
from beets import ui
|
||||
from beets.ui import print_, input_, decargs, show_path_changes
|
||||
from beets.ui import print_, input_, decargs, show_path_changes, \
|
||||
print_newline_layout, print_column_layout
|
||||
from beets import autotag
|
||||
from beets.autotag import Recommendation
|
||||
from beets.autotag import hooks
|
||||
|
|
@ -159,7 +161,6 @@ default_commands.append(fields_cmd)
|
|||
# help: Print help text for commands
|
||||
|
||||
class HelpCommand(ui.Subcommand):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
'help', aliases=('?',),
|
||||
|
|
@ -189,57 +190,78 @@ def disambig_string(info):
|
|||
provides context that helps disambiguate similar-looking albums and
|
||||
tracks.
|
||||
"""
|
||||
disambig = []
|
||||
if info.data_source and info.data_source != 'MusicBrainz':
|
||||
disambig.append(info.data_source)
|
||||
|
||||
if isinstance(info, hooks.AlbumInfo):
|
||||
if info.media:
|
||||
if info.mediums and info.mediums > 1:
|
||||
disambig.append('{}x{}'.format(
|
||||
info.mediums, info.media
|
||||
))
|
||||
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)
|
||||
disambig = get_album_disambig_fields(info)
|
||||
elif isinstance(info, hooks.TrackInfo):
|
||||
disambig = get_singleton_disambig_fields(info)
|
||||
else:
|
||||
return ''
|
||||
|
||||
if isinstance(info, hooks.TrackInfo):
|
||||
if info.index:
|
||||
disambig.append("Index {}".format(str(info.index)))
|
||||
if info.track_alt:
|
||||
disambig.append("Track {}".format(info.track_alt))
|
||||
if (config['import']['singleton_album_disambig'].get()
|
||||
and info.get('album')):
|
||||
disambig.append("[{}]".format(info.album))
|
||||
return ', '.join(disambig)
|
||||
|
||||
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):
|
||||
"""Formats a distance (a float) as a colorized similarity percentage
|
||||
string.
|
||||
"""
|
||||
out = '%.1f%%' % ((1 - dist) * 100)
|
||||
if dist <= config['match']['strong_rec_thresh'].as_number():
|
||||
out = ui.colorize('text_success', out)
|
||||
elif dist <= config['match']['medium_rec_thresh'].as_number():
|
||||
out = ui.colorize('text_warning', out)
|
||||
else:
|
||||
out = ui.colorize('text_error', out)
|
||||
return out
|
||||
string = "{:.1f}%".format(((1 - dist) * 100))
|
||||
return dist_colorize(string, dist)
|
||||
|
||||
|
||||
def penalty_string(distance, limit=None):
|
||||
|
|
@ -255,24 +277,172 @@ def penalty_string(distance, limit=None):
|
|||
if penalties:
|
||||
if limit and len(penalties) > limit:
|
||||
penalties = penalties[:limit] + ['...']
|
||||
return ui.colorize('text_warning', '(%s)' % ', '.join(penalties))
|
||||
# Prefix penalty string with U+2260: Not Equal To
|
||||
penalty_string = "\u2260 {}".format(", ".join(penalties))
|
||||
return ui.colorize("changed", penalty_string)
|
||||
|
||||
|
||||
def show_change(cur_artist, cur_album, match):
|
||||
"""Print out a representation of the changes that will be made if an
|
||||
album's tags are changed according to `match`, which must be an AlbumMatch
|
||||
object.
|
||||
class ChangeRepresentation(object):
|
||||
"""Keeps track of all information needed to generate a (colored) text
|
||||
representation of the changes that will be made if an album or singleton's
|
||||
tags are changed according to `match`, which must be an AlbumMatch or
|
||||
TrackMatch object, accordingly.
|
||||
"""
|
||||
def show_album(artist, album):
|
||||
if artist:
|
||||
album_description = f' {artist} - {album}'
|
||||
elif album:
|
||||
album_description = ' %s' % album
|
||||
else:
|
||||
album_description = ' (unknown album)'
|
||||
print_(album_description)
|
||||
|
||||
def format_index(track_info):
|
||||
cur_artist = None
|
||||
# cur_album set if album, cur_title set if singleton
|
||||
cur_album = None
|
||||
cur_title = None
|
||||
match = None
|
||||
indent_header = ""
|
||||
indent_detail = ""
|
||||
|
||||
def __init__(self):
|
||||
# Read match header indentation width from config.
|
||||
match_header_indent_width = config["ui"]["import"]["indentation"][
|
||||
"match_header"
|
||||
].as_number()
|
||||
self.indent_header = ui.indent(match_header_indent_width)
|
||||
|
||||
# Read match detail indentation width from config.
|
||||
match_detail_indent_width = config["ui"]["import"]["indentation"][
|
||||
"match_details"
|
||||
].as_number()
|
||||
self.indent_detail = ui.indent(match_detail_indent_width)
|
||||
|
||||
# Read match tracklist indentation width from config
|
||||
match_tracklist_indent_width = config["ui"]["import"]["indentation"][
|
||||
"match_tracklist"
|
||||
].as_number()
|
||||
self.indent_tracklist = ui.indent(match_tracklist_indent_width)
|
||||
self.layout = config["ui"]["import"]["layout"].as_choice(
|
||||
{
|
||||
"column": 0,
|
||||
"newline": 1,
|
||||
}
|
||||
)
|
||||
|
||||
def print_layout(self, indent, left, right, separator=" -> ",
|
||||
max_width=None):
|
||||
if not max_width:
|
||||
# If no max_width provided, use terminal width
|
||||
max_width = ui.term_width()
|
||||
if self.layout == 0:
|
||||
print_column_layout(indent, left, right, separator, max_width)
|
||||
else:
|
||||
print_newline_layout(indent, left, right, separator, max_width)
|
||||
|
||||
def show_match_header(self):
|
||||
"""Print out a 'header' identifying the suggested match (album name,
|
||||
artist name,...) and summarizing the changes that would be made should
|
||||
the user accept the match.
|
||||
"""
|
||||
# Print newline at beginning of change block.
|
||||
print_("")
|
||||
|
||||
# 'Match' line and similarity.
|
||||
print_(self.indent_header +
|
||||
f"Match ({dist_string(self.match.distance)}):")
|
||||
|
||||
if self.match.info.get("album"):
|
||||
# Matching an album - print that
|
||||
artist_album_str = f"{self.match.info.artist}" + \
|
||||
f" - {self.match.info.album}"
|
||||
else:
|
||||
# Matching a single track
|
||||
artist_album_str = f"{self.match.info.artist}" + \
|
||||
f" - {self.match.info.title}"
|
||||
print_(
|
||||
self.indent_header +
|
||||
dist_colorize(artist_album_str, self.match.distance)
|
||||
)
|
||||
|
||||
# Penalties.
|
||||
penalties = penalty_string(self.match.distance)
|
||||
if penalties:
|
||||
print_(self.indent_header + penalties)
|
||||
|
||||
# Disambiguation.
|
||||
disambig = disambig_string(self.match.info)
|
||||
if disambig:
|
||||
print_(self.indent_header + disambig)
|
||||
|
||||
# Data URL.
|
||||
if self.match.info.data_url:
|
||||
url = ui.colorize("text_faint", f"{self.match.info.data_url}")
|
||||
print_(self.indent_header + url)
|
||||
|
||||
def show_match_details(self):
|
||||
"""Print out the details of the match, including changes in album name
|
||||
and artist name.
|
||||
"""
|
||||
# Artist.
|
||||
artist_l, artist_r = self.cur_artist or "", self.match.info.artist
|
||||
if artist_r == VARIOUS_ARTISTS:
|
||||
# Hide artists for VA releases.
|
||||
artist_l, artist_r = "", ""
|
||||
if artist_l != artist_r:
|
||||
artist_l, artist_r = ui.colordiff(artist_l, artist_r)
|
||||
# Prefix with U+2260: Not Equal To
|
||||
left = {
|
||||
"prefix": ui.colorize("changed", "\u2260") + " Artist: ",
|
||||
"contents": artist_l,
|
||||
"suffix": "",
|
||||
}
|
||||
right = {"prefix": "", "contents": artist_r, "suffix": ""}
|
||||
self.print_layout(self.indent_detail, left, right)
|
||||
|
||||
else:
|
||||
print_(self.indent_detail + "*", "Artist:", artist_r)
|
||||
|
||||
if self.cur_album:
|
||||
# Album
|
||||
album_l, album_r = self.cur_album or "", self.match.info.album
|
||||
if (
|
||||
self.cur_album != self.match.info.album
|
||||
and self.match.info.album != VARIOUS_ARTISTS
|
||||
):
|
||||
album_l, album_r = ui.colordiff(album_l, album_r)
|
||||
# Prefix with U+2260: Not Equal To
|
||||
left = {
|
||||
"prefix": ui.colorize("changed", "\u2260") + " Album: ",
|
||||
"contents": album_l,
|
||||
"suffix": "",
|
||||
}
|
||||
right = {"prefix": "", "contents": album_r, "suffix": ""}
|
||||
self.print_layout(self.indent_detail, left, right)
|
||||
else:
|
||||
print_(self.indent_detail + "*", "Album:", album_r)
|
||||
elif self.cur_title:
|
||||
# Title - for singletons
|
||||
title_l, title_r = self.cur_title or "", self.match.info.title
|
||||
if self.cur_title != self.match.info.title:
|
||||
title_l, title_r = ui.colordiff(title_l, title_r)
|
||||
# Prefix with U+2260: Not Equal To
|
||||
left = {
|
||||
"prefix": ui.colorize("changed", "\u2260") + " Title: ",
|
||||
"contents": title_l,
|
||||
"suffix": "",
|
||||
}
|
||||
right = {"prefix": "", "contents": title_r, "suffix": ""}
|
||||
self.print_layout(self.indent_detail, left, right)
|
||||
else:
|
||||
print_(self.indent_detail + "*", "Title:", title_r)
|
||||
|
||||
def make_medium_info_line(self, track_info):
|
||||
"""Construct a line with the current medium's info."""
|
||||
media = self.match.info.media or "Media"
|
||||
# Build output string.
|
||||
if self.match.info.mediums > 1 and track_info.disctitle:
|
||||
return f"* {media} {track_info.medium}: {track_info.disctitle}"
|
||||
elif self.match.info.mediums > 1:
|
||||
return f"* {media} {track_info.medium}"
|
||||
elif track_info.disctitle:
|
||||
return f"* {media}: {track_info.disctitle}"
|
||||
else:
|
||||
return ""
|
||||
|
||||
def format_index(self, track_info):
|
||||
"""Return a string representing the track index of the given
|
||||
TrackInfo or Item object.
|
||||
"""
|
||||
|
|
@ -280,7 +450,7 @@ def show_change(cur_artist, cur_album, match):
|
|||
index = track_info.index
|
||||
medium_index = track_info.medium_index
|
||||
medium = track_info.medium
|
||||
mediums = match.info.mediums
|
||||
mediums = self.match.info.mediums
|
||||
else:
|
||||
index = medium_index = track_info.track
|
||||
medium = track_info.disc
|
||||
|
|
@ -294,205 +464,271 @@ def show_change(cur_artist, cur_album, match):
|
|||
else:
|
||||
return str(index)
|
||||
|
||||
# Identify the album in question.
|
||||
if cur_artist != match.info.artist or \
|
||||
(cur_album != match.info.album and
|
||||
match.info.album != VARIOUS_ARTISTS):
|
||||
artist_l, artist_r = cur_artist or '', match.info.artist
|
||||
album_l, album_r = cur_album or '', match.info.album
|
||||
if artist_r == VARIOUS_ARTISTS:
|
||||
# Hide artists for VA releases.
|
||||
artist_l, artist_r = '', ''
|
||||
|
||||
if config['artist_credit']:
|
||||
artist_r = match.info.artist_credit
|
||||
|
||||
artist_l, artist_r = ui.colordiff(artist_l, artist_r)
|
||||
album_l, album_r = ui.colordiff(album_l, album_r)
|
||||
|
||||
print_("Correcting tags from:")
|
||||
show_album(artist_l, album_l)
|
||||
print_("To:")
|
||||
show_album(artist_r, album_r)
|
||||
else:
|
||||
print_("Tagging:\n {0.artist} - {0.album}".format(match.info))
|
||||
|
||||
# Data URL.
|
||||
if match.info.data_url:
|
||||
print_('URL:\n %s' % match.info.data_url)
|
||||
|
||||
# Info line.
|
||||
info = []
|
||||
# Similarity.
|
||||
info.append('(Similarity: %s)' % dist_string(match.distance))
|
||||
# Penalties.
|
||||
penalties = penalty_string(match.distance)
|
||||
if penalties:
|
||||
info.append(penalties)
|
||||
# Disambiguation.
|
||||
disambig = disambig_string(match.info)
|
||||
if disambig:
|
||||
info.append(ui.colorize('text_highlight_minor', '(%s)' % disambig))
|
||||
print_(' '.join(info))
|
||||
|
||||
# Tracks.
|
||||
pairs = list(match.mapping.items())
|
||||
pairs.sort(key=lambda item_and_track_info: item_and_track_info[1].index)
|
||||
|
||||
# Build up LHS and RHS for track difference display. The `lines` list
|
||||
# contains ``(lhs, rhs, width)`` tuples where `width` is the length (in
|
||||
# characters) of the uncolorized LHS.
|
||||
lines = []
|
||||
medium = disctitle = None
|
||||
for item, track_info in pairs:
|
||||
|
||||
# Medium number and title.
|
||||
if medium != track_info.medium or disctitle != track_info.disctitle:
|
||||
media = match.info.media or 'Media'
|
||||
if match.info.mediums > 1 and track_info.disctitle:
|
||||
lhs = '{} {}: {}'.format(media, track_info.medium,
|
||||
track_info.disctitle)
|
||||
elif match.info.mediums > 1:
|
||||
lhs = f'{media} {track_info.medium}'
|
||||
elif track_info.disctitle:
|
||||
lhs = f'{media}: {track_info.disctitle}'
|
||||
def make_track_numbers(self, item, track_info):
|
||||
"""Format colored track indices."""
|
||||
cur_track = self.format_index(item)
|
||||
new_track = self.format_index(track_info)
|
||||
templ = "(#{})"
|
||||
changed = False
|
||||
# Choose color based on change.
|
||||
if cur_track != new_track:
|
||||
changed = True
|
||||
if item.track in (track_info.index, track_info.medium_index):
|
||||
highlight_color = "text_highlight_minor"
|
||||
else:
|
||||
lhs = None
|
||||
if lhs:
|
||||
lines.append((lhs, '', 0))
|
||||
medium, disctitle = track_info.medium, track_info.disctitle
|
||||
highlight_color = "text_highlight"
|
||||
else:
|
||||
highlight_color = "text_faint"
|
||||
|
||||
# Titles.
|
||||
cur_track = templ.format(cur_track)
|
||||
new_track = templ.format(new_track)
|
||||
lhs_track = ui.colorize(highlight_color, cur_track)
|
||||
rhs_track = ui.colorize(highlight_color, new_track)
|
||||
return lhs_track, rhs_track, changed
|
||||
|
||||
@staticmethod
|
||||
def make_track_titles(item, track_info):
|
||||
"""Format colored track titles."""
|
||||
new_title = track_info.title
|
||||
if not item.title.strip():
|
||||
# If there's no title, we use the filename.
|
||||
# If there's no title, we use the filename. Don't colordiff.
|
||||
cur_title = displayable_path(os.path.basename(item.path))
|
||||
lhs, rhs = cur_title, new_title
|
||||
return cur_title, new_title, True
|
||||
else:
|
||||
# If there is a title, highlight differences.
|
||||
cur_title = item.title.strip()
|
||||
lhs, rhs = ui.colordiff(cur_title, new_title)
|
||||
lhs_width = len(cur_title)
|
||||
cur_col, new_col = ui.colordiff(cur_title, new_title)
|
||||
return cur_col, new_col, cur_title != new_title
|
||||
|
||||
@staticmethod
|
||||
def make_track_lengths(item, track_info):
|
||||
"""Format colored track lengths."""
|
||||
changed = False
|
||||
if (
|
||||
item.length
|
||||
and track_info.length
|
||||
and abs(item.length - track_info.length)
|
||||
>= config["ui"]["length_diff_thresh"].as_number()
|
||||
):
|
||||
highlight_color = "text_highlight"
|
||||
changed = True
|
||||
else:
|
||||
highlight_color = "text_highlight_minor"
|
||||
|
||||
# Handle nonetype lengths by setting to 0
|
||||
cur_length0 = item.length if item.length else 0
|
||||
new_length0 = track_info.length if track_info.length else 0
|
||||
# format into string
|
||||
cur_length = f"({ui.human_seconds_short(cur_length0)})"
|
||||
new_length = f"({ui.human_seconds_short(new_length0)})"
|
||||
# colorize
|
||||
lhs_length = ui.colorize(highlight_color, cur_length)
|
||||
rhs_length = ui.colorize(highlight_color, new_length)
|
||||
|
||||
return lhs_length, rhs_length, changed
|
||||
|
||||
def make_line(self, item, track_info):
|
||||
"""Extract changes from item -> new TrackInfo object, and colorize
|
||||
appropriately. Returns (lhs, rhs) for column printing.
|
||||
"""
|
||||
# Track titles.
|
||||
lhs_title, rhs_title, diff_title = \
|
||||
self.make_track_titles(item, track_info)
|
||||
# Track number change.
|
||||
cur_track, new_track = format_index(item), format_index(track_info)
|
||||
if cur_track != new_track:
|
||||
if item.track in (track_info.index, track_info.medium_index):
|
||||
color = 'text_highlight_minor'
|
||||
else:
|
||||
color = 'text_highlight'
|
||||
templ = ui.colorize(color, ' (#{0})')
|
||||
lhs += templ.format(cur_track)
|
||||
rhs += templ.format(new_track)
|
||||
lhs_width += len(cur_track) + 4
|
||||
|
||||
lhs_track, rhs_track, diff_track = \
|
||||
self.make_track_numbers(item, track_info)
|
||||
# Length change.
|
||||
if item.length and track_info.length and \
|
||||
abs(item.length - track_info.length) > \
|
||||
config['ui']['length_diff_thresh'].as_number():
|
||||
cur_length = ui.human_seconds_short(item.length)
|
||||
new_length = ui.human_seconds_short(track_info.length)
|
||||
templ = ui.colorize('text_highlight', ' ({0})')
|
||||
lhs += templ.format(cur_length)
|
||||
rhs += templ.format(new_length)
|
||||
lhs_width += len(cur_length) + 3
|
||||
lhs_length, rhs_length, diff_length = \
|
||||
self.make_track_lengths(item, track_info)
|
||||
|
||||
# Penalties.
|
||||
penalties = penalty_string(match.distance.tracks[track_info])
|
||||
if penalties:
|
||||
rhs += ' %s' % penalties
|
||||
changed = diff_title or diff_track or diff_length
|
||||
|
||||
if lhs != rhs:
|
||||
lines.append((' * %s' % lhs, rhs, lhs_width))
|
||||
elif config['import']['detail']:
|
||||
lines.append((' * %s' % lhs, '', lhs_width))
|
||||
# Construct lhs and rhs dicts.
|
||||
# Previously, we printed the penalties, however this is no longer
|
||||
# the case, thus the 'info' dictionary is unneeded.
|
||||
# penalties = penalty_string(self.match.distance.tracks[track_info])
|
||||
|
||||
# Print each track in two columns, or across two lines.
|
||||
col_width = (ui.term_width() - len(''.join([' * ', ' -> ']))) // 2
|
||||
if lines:
|
||||
max_width = max(w for _, _, w in lines)
|
||||
for lhs, rhs, lhs_width in lines:
|
||||
if not rhs:
|
||||
print_(lhs)
|
||||
elif max_width > col_width:
|
||||
print_(f'{lhs} ->\n {rhs}')
|
||||
else:
|
||||
pad = max_width - lhs_width
|
||||
print_('{}{} -> {}'.format(lhs, ' ' * pad, rhs))
|
||||
prefix = ui.colorize("changed", "\u2260 ") if changed else "* "
|
||||
lhs = {
|
||||
"prefix": prefix + lhs_track + " ",
|
||||
"contents": lhs_title,
|
||||
"suffix": " " + lhs_length,
|
||||
}
|
||||
rhs = {"prefix": "", "contents": "", "suffix": ""}
|
||||
if not changed:
|
||||
# Only return the left side, as nothing changed.
|
||||
return (lhs, rhs)
|
||||
else:
|
||||
# Construct a dictionary for the "changed to" side
|
||||
rhs = {
|
||||
"prefix": rhs_track + " ",
|
||||
"contents": rhs_title,
|
||||
"suffix": " " + rhs_length,
|
||||
}
|
||||
return (lhs, rhs)
|
||||
|
||||
# Missing and unmatched tracks.
|
||||
if match.extra_tracks:
|
||||
print_('Missing tracks ({}/{} - {:.1%}):'.format(
|
||||
len(match.extra_tracks),
|
||||
len(match.info.tracks),
|
||||
len(match.extra_tracks) / len(match.info.tracks)
|
||||
))
|
||||
pad_width = max(len(track_info.title) for track_info in
|
||||
match.extra_tracks)
|
||||
for track_info in match.extra_tracks:
|
||||
line = ' ! {0: <{width}} (#{1: >2})'.format(track_info.title,
|
||||
format_index(track_info),
|
||||
width=pad_width)
|
||||
if track_info.length:
|
||||
line += ' (%s)' % ui.human_seconds_short(track_info.length)
|
||||
print_(ui.colorize('text_warning', line))
|
||||
if match.extra_items:
|
||||
print_('Unmatched tracks ({}):'.format(len(match.extra_items)))
|
||||
pad_width = max(len(item.title) for item in match.extra_items)
|
||||
for item in match.extra_items:
|
||||
line = ' ! {0: <{width}} (#{1: >2})'.format(item.title,
|
||||
format_index(item),
|
||||
width=pad_width)
|
||||
if item.length:
|
||||
line += ' (%s)' % ui.human_seconds_short(item.length)
|
||||
print_(ui.colorize('text_warning', line))
|
||||
def print_tracklist(self, lines):
|
||||
"""Calculates column widths for tracks stored as line tuples:
|
||||
(left, right). Then prints each line of tracklist.
|
||||
"""
|
||||
if len(lines) == 0:
|
||||
# If no lines provided, e.g. details not required, do nothing.
|
||||
return
|
||||
|
||||
def get_width(side):
|
||||
"""Return the width of left or right in uncolorized characters."""
|
||||
try:
|
||||
return len(
|
||||
ui.uncolorize(
|
||||
" ".join([side["prefix"],
|
||||
side["contents"],
|
||||
side["suffix"]])
|
||||
)
|
||||
)
|
||||
except KeyError:
|
||||
# An empty dictionary -> Nothing to report
|
||||
return 0
|
||||
|
||||
# Check how to fit content into terminal window
|
||||
indent_width = len(self.indent_tracklist)
|
||||
terminal_width = ui.term_width()
|
||||
joiner_width = len("".join(["* ", " -> "]))
|
||||
col_width = (terminal_width - indent_width - joiner_width) // 2
|
||||
max_width_l = max(get_width(line_tuple[0]) for line_tuple in lines)
|
||||
max_width_r = max(get_width(line_tuple[1]) for line_tuple in lines)
|
||||
|
||||
if (
|
||||
(max_width_l <= col_width)
|
||||
and (max_width_r <= col_width)
|
||||
or (
|
||||
((max_width_l > col_width) or (max_width_r > col_width))
|
||||
and ((max_width_l + max_width_r) <= col_width * 2)
|
||||
)
|
||||
):
|
||||
# All content fits. Either both maximum widths are below column
|
||||
# widths, or one of the columns is larger than allowed but the
|
||||
# other is smaller than allowed.
|
||||
# In this case we can afford to shrink the columns to fit their
|
||||
# largest string
|
||||
col_width_l = max_width_l
|
||||
col_width_r = max_width_r
|
||||
else:
|
||||
# Not all content fits - stick with original half/half split
|
||||
col_width_l = col_width
|
||||
col_width_r = col_width
|
||||
|
||||
# Print out each line, using the calculated width from above.
|
||||
for left, right in lines:
|
||||
left["width"] = col_width_l
|
||||
right["width"] = col_width_r
|
||||
self.print_layout(self.indent_tracklist, left, right)
|
||||
|
||||
|
||||
class AlbumChange(ChangeRepresentation):
|
||||
"""Album change representation, setting cur_album"""
|
||||
|
||||
def __init__(self, cur_artist, cur_album, match):
|
||||
super(AlbumChange, self).__init__()
|
||||
self.cur_artist = cur_artist
|
||||
self.cur_album = cur_album
|
||||
self.match = match
|
||||
|
||||
def show_match_tracks(self):
|
||||
"""Print out the tracks of the match, summarizing changes the match
|
||||
suggests for them.
|
||||
"""
|
||||
# Tracks.
|
||||
# match is an AlbumMatch named tuple, mapping is a dict
|
||||
# Sort the pairs by the track_info index (at index 1 of the namedtuple)
|
||||
pairs = list(self.match.mapping.items())
|
||||
pairs.sort(
|
||||
key=lambda item_and_track_info: item_and_track_info[1].index)
|
||||
# Build up LHS and RHS for track difference display. The `lines` list
|
||||
# contains `(left, right)` tuples.
|
||||
lines = []
|
||||
medium = disctitle = None
|
||||
for item, track_info in pairs:
|
||||
# If the track is the first on a new medium, show medium
|
||||
# number and title.
|
||||
if medium != track_info.medium or \
|
||||
disctitle != track_info.disctitle:
|
||||
# Create header for new medium
|
||||
header = self.make_medium_info_line(track_info)
|
||||
if header != "":
|
||||
# Print tracks from previous medium
|
||||
self.print_tracklist(lines)
|
||||
lines = []
|
||||
print_(self.indent_detail + header)
|
||||
# Save new medium details for future comparison.
|
||||
medium, disctitle = track_info.medium, track_info.disctitle
|
||||
|
||||
if config["import"]["detail"]:
|
||||
# Construct the line tuple for the track.
|
||||
left, right = self.make_line(item, track_info)
|
||||
lines.append((left, right))
|
||||
self.print_tracklist(lines)
|
||||
|
||||
# Missing and unmatched tracks.
|
||||
if self.match.extra_tracks:
|
||||
print_(
|
||||
"Missing tracks ({0}/{1} - {2:.1%}):".format(
|
||||
len(self.match.extra_tracks),
|
||||
len(self.match.info.tracks),
|
||||
len(self.match.extra_tracks) / len(self.match.info.tracks),
|
||||
)
|
||||
)
|
||||
for track_info in self.match.extra_tracks:
|
||||
line = f" ! {track_info.title} (#{self.format_index(track_info)})"
|
||||
if track_info.length:
|
||||
line += f" ({ui.human_seconds_short(track_info.length)})"
|
||||
print_(ui.colorize("text_warning", line))
|
||||
if self.match.extra_items:
|
||||
print_(f"Unmatched tracks ({len(self.match.extra_items)}):")
|
||||
for item in self.match.extra_items:
|
||||
line = " ! {} (#{})".format(item.title, self.format_index(item))
|
||||
if item.length:
|
||||
line += " ({})".format(ui.human_seconds_short(item.length))
|
||||
print_(ui.colorize("text_warning", line))
|
||||
|
||||
|
||||
class TrackChange(ChangeRepresentation):
|
||||
"""Track change representation, comparing item with match."""
|
||||
|
||||
def __init__(self, cur_artist, cur_title, match):
|
||||
super(TrackChange, self).__init__()
|
||||
self.cur_artist = cur_artist
|
||||
self.cur_title = cur_title
|
||||
self.match = match
|
||||
|
||||
|
||||
def show_change(cur_artist, cur_album, match):
|
||||
"""Print out a representation of the changes that will be made if an
|
||||
album's tags are changed according to `match`, which must be an AlbumMatch
|
||||
object.
|
||||
"""
|
||||
change = AlbumChange(cur_artist=cur_artist, cur_album=cur_album,
|
||||
match=match)
|
||||
|
||||
# Print the match header.
|
||||
change.show_match_header()
|
||||
|
||||
# Print the match details.
|
||||
change.show_match_details()
|
||||
|
||||
# Print the match tracks.
|
||||
change.show_match_tracks()
|
||||
|
||||
|
||||
def show_item_change(item, match):
|
||||
"""Print out the change that would occur by tagging `item` with the
|
||||
metadata from `match`, a TrackMatch object.
|
||||
"""
|
||||
cur_artist, new_artist = item.artist, match.info.artist
|
||||
cur_title, new_title = item.title, match.info.title
|
||||
cur_album = item.album if item.album else ""
|
||||
new_album = match.info.album if match.info.album else ""
|
||||
|
||||
if (cur_artist != new_artist or cur_title != new_title
|
||||
or cur_album != new_album):
|
||||
cur_artist, new_artist = ui.colordiff(cur_artist, new_artist)
|
||||
cur_title, new_title = ui.colordiff(cur_title, new_title)
|
||||
cur_album, new_album = ui.colordiff(cur_album, new_album)
|
||||
|
||||
print_("Correcting track tags from:")
|
||||
print_(f" {cur_artist} - {cur_title}")
|
||||
if cur_album:
|
||||
print_(f" Album: {cur_album}")
|
||||
print_("To:")
|
||||
print_(f" {new_artist} - {new_title}")
|
||||
if new_album:
|
||||
print_(f" Album: {new_album}")
|
||||
|
||||
else:
|
||||
print_(f"Tagging track: {cur_artist} - {cur_title}")
|
||||
if cur_album:
|
||||
print_(f" Album: {new_album}")
|
||||
|
||||
# Data URL.
|
||||
if match.info.data_url:
|
||||
print_('URL:\n %s' % match.info.data_url)
|
||||
|
||||
# Info line.
|
||||
info = []
|
||||
# Similarity.
|
||||
info.append('(Similarity: %s)' % dist_string(match.distance))
|
||||
# Penalties.
|
||||
penalties = penalty_string(match.distance)
|
||||
if penalties:
|
||||
info.append(penalties)
|
||||
# Disambiguation.
|
||||
disambig = disambig_string(match.info)
|
||||
if disambig:
|
||||
info.append(ui.colorize('text_highlight_minor', '(%s)' % disambig))
|
||||
print_(' '.join(info))
|
||||
change = TrackChange(cur_artist=item.artist, cur_title=item.title,
|
||||
match=match)
|
||||
# Print the match header.
|
||||
change.show_match_header()
|
||||
# Print the match details.
|
||||
change.show_match_details()
|
||||
|
||||
|
||||
def summarize_items(items, singleton):
|
||||
|
|
@ -625,36 +861,40 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None,
|
|||
|
||||
if not bypass_candidates:
|
||||
# Display list of candidates.
|
||||
print_("")
|
||||
print_('Finding tags for {} "{} - {}".'.format(
|
||||
'track' if singleton else 'album',
|
||||
item.artist if singleton else cur_artist,
|
||||
item.title if singleton else cur_album,
|
||||
))
|
||||
|
||||
print_('Candidates:')
|
||||
print_(ui.indent(2) + 'Candidates:')
|
||||
for i, match in enumerate(candidates):
|
||||
# Index, metadata, and distance.
|
||||
line = [
|
||||
'{}.'.format(i + 1),
|
||||
'{} - {}'.format(
|
||||
match.info.artist,
|
||||
match.info.title if singleton else match.info.album,
|
||||
),
|
||||
'({})'.format(dist_string(match.distance)),
|
||||
]
|
||||
index0 = "{0}.".format(i + 1)
|
||||
index = dist_colorize(index0, match.distance)
|
||||
dist = "({:.1f}%)".format((1 - match.distance) * 100)
|
||||
distance = dist_colorize(dist, match.distance)
|
||||
metadata = "{0} - {1}".format(
|
||||
match.info.artist,
|
||||
match.info.title if singleton else match.info.album,
|
||||
)
|
||||
if i == 0:
|
||||
metadata = dist_colorize(metadata, match.distance)
|
||||
else:
|
||||
metadata = ui.colorize("text_highlight_minor", metadata)
|
||||
line1 = [index, distance, metadata]
|
||||
print_(ui.indent(2) + " ".join(line1))
|
||||
|
||||
# Penalties.
|
||||
penalties = penalty_string(match.distance, 3)
|
||||
if penalties:
|
||||
line.append(penalties)
|
||||
print_(ui.indent(13) + penalties)
|
||||
|
||||
# Disambiguation
|
||||
disambig = disambig_string(match.info)
|
||||
if disambig:
|
||||
line.append(ui.colorize('text_highlight_minor',
|
||||
'(%s)' % disambig))
|
||||
|
||||
print_(' '.join(line))
|
||||
print_(ui.indent(13) + disambig)
|
||||
|
||||
# Ask the user for a choice.
|
||||
sel = ui.input_options(choice_opts,
|
||||
|
|
@ -754,8 +994,12 @@ class TerminalImportSession(importer.ImportSession):
|
|||
"""
|
||||
# Show what we're tagging.
|
||||
print_()
|
||||
print_(displayable_path(task.paths, '\n') +
|
||||
' ({} items)'.format(len(task.items)))
|
||||
|
||||
path_str0 = displayable_path(task.paths, '\n')
|
||||
path_str = ui.colorize('import_path', path_str0)
|
||||
items_str0 = '({} items)'.format(len(task.items))
|
||||
items_str = ui.colorize('import_path_items', items_str0)
|
||||
print_(' '.join([path_str, items_str]))
|
||||
|
||||
# Let plugins display info or prompt the user before we go through the
|
||||
# process of selecting candidate.
|
||||
|
|
|
|||
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']:
|
||||
continue
|
||||
|
||||
if preferred_width:
|
||||
yield item['thumbnails'][preferred_width]
|
||||
else:
|
||||
yield item['image']
|
||||
# If there is a pre-sized thumbnail of the desired size
|
||||
# we select it. Otherwise, we return the raw image.
|
||||
image_url: str = 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:
|
||||
pass
|
||||
|
||||
|
|
@ -422,7 +427,7 @@ class CoverArtArchive(RemoteArtSource):
|
|||
yield self._candidate(url=url, match=Candidate.MATCH_EXACT)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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:
|
||||
|
||||
* :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
|
||||
calculate the BPM of the audio.
|
||||
: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:
|
||||
|
||||
|
|
|
|||
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.
|
||||
- **maxwidth**: A maximum image width to downscale fetched images if they are
|
||||
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
|
||||
``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
|
||||
|
|
@ -269,7 +272,21 @@ Spotify backend is enabled by default and will update album art if a valid Spoti
|
|||
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
|
||||
----------------------------
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ following to your configuration::
|
|||
|
||||
absubmit
|
||||
acousticbrainz
|
||||
advancedrewrite
|
||||
albumtypes
|
||||
aura
|
||||
autobpm
|
||||
|
|
@ -246,6 +247,9 @@ Path Formats
|
|||
:doc:`rewrite <rewrite>`
|
||||
Substitute values in path formats.
|
||||
|
||||
:doc:`advancedrewrite <advancedrewrite>`
|
||||
Substitute field values for items matching a query.
|
||||
|
||||
:doc:`substitute <substitute>`
|
||||
As an alternative to :doc:`rewrite <rewrite>`, use this plugin. The main
|
||||
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
|
||||
:doc:`/plugins/discogs`, too.
|
||||
|
||||
.. _ui_options:
|
||||
|
||||
UI Options
|
||||
----------
|
||||
|
||||
|
|
@ -419,6 +421,8 @@ support ANSI colors.
|
|||
still respected, but a deprecation message will be shown until your
|
||||
top-level `color` configuration has been nested under `ui`.
|
||||
|
||||
.. _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::
|
||||
|
||||
ui:
|
||||
color: yes
|
||||
colors:
|
||||
text_success: green
|
||||
text_warning: yellow
|
||||
text_error: red
|
||||
text_highlight: red
|
||||
text_highlight_minor: lightgray
|
||||
action_default: turquoise
|
||||
action: blue
|
||||
text_success: ['bold', 'green']
|
||||
text_warning: ['bold', 'yellow']
|
||||
text_error: ['bold', 'red']
|
||||
text_highlight: ['bold', 'red']
|
||||
text_highlight_minor: ['white']
|
||||
action_default: ['bold', 'cyan']
|
||||
action: ['bold', '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,
|
||||
purple (darkmagenta), teal (darkcyan), lightgray, darkgray, red, green,
|
||||
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
|
||||
----------------
|
||||
|
|
|
|||
|
|
@ -130,6 +130,39 @@ class CAAHelper():
|
|||
}
|
||||
],
|
||||
"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 = """{
|
||||
"images": [
|
||||
|
|
@ -155,6 +188,23 @@ class CAAHelper():
|
|||
],
|
||||
"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):
|
||||
responses.add(responses.GET, url, body=json,
|
||||
|
|
@ -521,6 +571,42 @@ class CoverArtArchiveTest(UseThePlugin, CAAHelper):
|
|||
self.assertEqual(len(responses.calls), 2)
|
||||
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):
|
||||
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',
|
||||
dist=0.1):
|
||||
"""Return an unicode string representing the changes"""
|
||||
items = items or self.items
|
||||
info = info or self.info
|
||||
mapping = dict(zip(items, info.tracks))
|
||||
config['ui']['color'] = False
|
||||
album_dist = distance(items, info, mapping)
|
||||
album_dist._penalties = {'album': [dist]}
|
||||
config['ui']['color'] = color
|
||||
config['import']['detail'] = True
|
||||
change_dist = distance(items, info, mapping)
|
||||
change_dist._penalties = {'album': [dist], 'artist': [dist]}
|
||||
commands.show_change(
|
||||
cur_artist,
|
||||
cur_album,
|
||||
autotag.AlbumMatch(album_dist, info, mapping, set(), set()),
|
||||
autotag.AlbumMatch(change_dist, info, mapping, set(), set()),
|
||||
)
|
||||
return self.io.getoutput().lower()
|
||||
|
||||
def test_null_change(self):
|
||||
msg = self._show_change()
|
||||
self.assertTrue('similarity: 90' in msg)
|
||||
self.assertTrue('tagging:' in msg)
|
||||
self.assertTrue('match (90.0%)' in msg)
|
||||
self.assertTrue('album, artist' in msg)
|
||||
|
||||
def test_album_data_change(self):
|
||||
msg = self._show_change(cur_artist='another artist',
|
||||
cur_album='another album')
|
||||
self.assertTrue('correcting tags from:' in msg)
|
||||
self.assertTrue('another artist -> the artist' in msg)
|
||||
self.assertTrue('another album -> the album' in msg)
|
||||
|
||||
def test_item_data_change(self):
|
||||
self.items[0].title = 'different'
|
||||
msg = self._show_change()
|
||||
self.assertTrue('different -> the title' in msg)
|
||||
self.assertTrue('different' in msg and 'the title' in msg)
|
||||
|
||||
def test_item_data_change_with_unicode(self):
|
||||
self.items[0].title = 'caf\xe9'
|
||||
msg = self._show_change()
|
||||
self.assertTrue('caf\xe9 -> the title' in msg)
|
||||
self.assertTrue(u'caf\xe9' in msg and 'the title' in msg)
|
||||
|
||||
def test_album_data_change_with_unicode(self):
|
||||
msg = self._show_change(cur_artist='caf\xe9',
|
||||
cur_album='another album')
|
||||
self.assertTrue('correcting tags from:' in msg)
|
||||
msg = self._show_change(cur_artist=u'caf\xe9',
|
||||
cur_album=u'another album')
|
||||
self.assertTrue(u'caf\xe9' in msg and 'the artist' in msg)
|
||||
|
||||
def test_item_data_change_title_missing(self):
|
||||
self.items[0].title = ''
|
||||
msg = re.sub(r' +', ' ', self._show_change())
|
||||
self.assertTrue('file.mp3 -> the title' in msg)
|
||||
self.assertTrue(u'file.mp3' in msg and 'the title' in msg)
|
||||
|
||||
def test_item_data_change_title_missing_with_unicode_filename(self):
|
||||
self.items[0].title = ''
|
||||
self.items[0].path = '/path/to/caf\xe9.mp3'.encode()
|
||||
msg = re.sub(r' +', ' ', self._show_change())
|
||||
self.assertTrue('caf\xe9.mp3 -> the title' in msg or
|
||||
'caf.mp3 ->' in msg)
|
||||
self.assertTrue(u'caf\xe9.mp3' in msg or
|
||||
u'caf.mp3' in msg)
|
||||
|
||||
def test_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))
|
||||
|
|
|
|||
Loading…
Reference in a new issue